diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index 9a93fd9d5c..bbaf138a23 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -290,7 +290,10 @@ public record ChatDataSnapshot( bool CommandsSupported = true, IReadOnlyDictionary? TimelineGenerations = null, IReadOnlyDictionary? HistoryRevisions = null, - IReadOnlyDictionary>? QueuedMessagesByThread = null); + IReadOnlyDictionary>? QueuedMessagesByThread = null, + // AUTHORITATIVE accepted session UUID per thread, projected under the state _gate with the SAME coherent + // timeline snapshot. Null/absent means EXPLICITLY unknown - never silently reuse another identity. + IReadOnlyDictionary? AcceptedSessionIds = null); /// /// Describes where the UI may send the next chat message. Distinct from diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs index a39bebcac7..ac47bd7179 100644 --- a/src/OpenClaw.Chat/ChatTimelineReducer.cs +++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs @@ -10,14 +10,37 @@ public static class ChatTimelineReducer private const int MaxTerminalToolCorrelations = 768; private const int MaxRetainedToolTurns = 2; - public static ChatTimelineState RebuildActiveToolTracking(ChatTimelineState state) + /// + /// Recomputes the active (in-progress) tool-call projection. Optional cancellation is observed at + /// entry, exit, and every 64 real entries of the outer scan and the inner correlation loop, so a large + /// full-history rebuild cannot run to completion after cancellation (the enclosing Task.Run token alone + /// is not sufficient). + /// + public static ChatTimelineState RebuildActiveToolTracking( + ChatTimelineState state, + CancellationToken cancellationToken = default, + Action? operationObserver = null) { + cancellationToken.ThrowIfCancellationRequested(); // stage entry var activeToolCallId = default(string); var activeToolCalls = System.Collections.Immutable.ImmutableDictionary.Empty; + // ONE shared operation budget across the outer entry scan AND every inner correlation loop, so the + // token is checked at most every 64 REAL operations regardless of how the work is nested (a + // per-loop counter could otherwise accumulate ~63 correlations x 63 entries between checks). + long operations = 0; + void Checkpoint() + { + if ((++operations & 63) != 0) + return; + operationObserver?.Invoke(operations); + cancellationToken.ThrowIfCancellationRequested(); + } + foreach (var entry in state.Entries) { + Checkpoint(); if (entry.Kind != ChatTimelineItemKind.ToolCall || entry.ToolResult is not (null or ChatToolCallStatus.InProgress)) { @@ -31,12 +54,15 @@ public static ChatTimelineState RebuildActiveToolTracking(ChatTimelineState stat correlationIds = correlationIds.Add(entry.ToolCallId); foreach (var correlationId in correlationIds) { + Checkpoint(); activeToolCalls = activeToolCalls.SetItem( CorrelationKey(entry.ToolRunId, entry.ToolLegacyTurn, correlationId), entry.Id); } } + operationObserver?.Invoke(operations); + cancellationToken.ThrowIfCancellationRequested(); // stage exit return state with { ActiveToolCallId = activeToolCallId, diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiApiCredentialStore.cs b/src/OpenClaw.Connection/LocalAi/LocalAiApiCredentialStore.cs index b15aa629db..7b68601259 100644 --- a/src/OpenClaw.Connection/LocalAi/LocalAiApiCredentialStore.cs +++ b/src/OpenClaw.Connection/LocalAi/LocalAiApiCredentialStore.cs @@ -52,7 +52,7 @@ private static string Read(string path) { if (new FileInfo(path).Length is < 1 or > 4096) throw new InvalidDataException("The protected Local AI API credential is invalid."); - byte[] plain = ProtectedData.Unprotect(File.ReadAllBytes(path), Entropy, DataProtectionScope.CurrentUser); + byte[] plain = ProtectedData.Unprotect(ReadProtectedBytes(path), Entropy, DataProtectionScope.CurrentUser); try { if (plain.Length != 32) @@ -61,4 +61,19 @@ private static string Read(string path) } finally { CryptographicOperations.ZeroMemory(plain); } } + + private static byte[] ReadProtectedBytes(string path) + { + // Concurrent first publication can briefly hold the winning file. Retry only + // Windows sharing/lock violations, with at most 500 ms of retry delays. + // Corruption, access denial and missing files still fail without rotation. + for (var attempt = 0; ; attempt++) + { + try { return File.ReadAllBytes(path); } + catch (IOException error) when (attempt < 20 && (error.HResult & 0xffff) is 32 or 33) + { + Thread.Sleep(25); + } + } + } } diff --git a/src/OpenClaw.HangCapture/OpenClaw.HangCapture.csproj b/src/OpenClaw.HangCapture/OpenClaw.HangCapture.csproj new file mode 100644 index 0000000000..ba09f61b95 --- /dev/null +++ b/src/OpenClaw.HangCapture/OpenClaw.HangCapture.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + enable + enable + OpenClaw.HangCapture + OpenClawTray.Services + true + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/OpenClaw.HangCapture/Program.cs b/src/OpenClaw.HangCapture/Program.cs new file mode 100644 index 0000000000..8a0ace20cf --- /dev/null +++ b/src/OpenClaw.HangCapture/Program.cs @@ -0,0 +1,11 @@ +using System; +using OpenClawTray.Services; + +// ISOLATED diagnostic host: exact-target (pid + raw FILETIME birth + thread id) hang capture that runs on its own +// bounded worker and writes a bounded, redacted, exclusively-named local receipt. It never activates the installed +// app, opens a listener, or registers a service/watchdog/schedule. +internal static class Program +{ + private static int Main(string[] args) + => HangCaptureHost.Run(args, new WindowsUiThreadStackSource(), Console.Out).ExitCode; +} \ No newline at end of file diff --git a/src/OpenClaw.Shared/AssemblyInfo.cs b/src/OpenClaw.Shared/AssemblyInfo.cs index 7414d855fb..1487f8e7d2 100644 --- a/src/OpenClaw.Shared/AssemblyInfo.cs +++ b/src/OpenClaw.Shared/AssemblyInfo.cs @@ -1,3 +1,5 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("OpenClaw.Shared.Tests")] +[assembly: InternalsVisibleTo("OpenClaw.Tray.WinUI")] +[assembly: InternalsVisibleTo("OpenClaw.Tray.Tests")] diff --git a/src/OpenClaw.Shared/HangCaptureReport.cs b/src/OpenClaw.Shared/HangCaptureReport.cs new file mode 100644 index 0000000000..4315bbf6eb --- /dev/null +++ b/src/OpenClaw.Shared/HangCaptureReport.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace OpenClaw.Shared; + +/// +/// U4 companion reliability: formats a local, credential-free UI hang report so a +/// freeze leaves evidence that can be correlated with companion/gateway logs. This is +/// formatting only: it never restarts anything and never reads app configuration. +/// +/// All collection is bounded up front: at most frames, +/// with per-frame and total character budgets enforced before any formatting or disk +/// write, so an unbounded or hostile enumerable can never be fully materialized. +/// +internal static class HangCaptureReport +{ + public const int DefaultMaxFrames = 40; + public const int DefaultMaxCharsPerFrame = 512; + public const int DefaultMaxTotalFrameChars = 16384; + + public const string UnknownTrigger = "unknown"; + + private static readonly string[] SensitiveMarkers = + { "token", "password", "secret", "authorization", "apikey", "api_key", "credential", "cookie" }; + + // Only known, non-sensitive trigger reasons are emitted. Anything else - including a + // caller-supplied reason that might embed a credential - collapses to "unknown". + private static readonly HashSet KnownTriggers = new(StringComparer.Ordinal) + { + "ui-thread-stall", "ui-freeze", "dispatcher-watchdog", "unresponsive", "manual", "unknown" + }; + + public static bool IsSensitiveLine(string? line) + { + if (string.IsNullOrEmpty(line)) return false; + foreach (var marker in SensitiveMarkers) + if (line.Contains(marker, StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + public static int RedactedLineCount(IEnumerable? frames) + => frames is null ? 0 : frames.Count(f => IsSensitiveLine(f)); + + /// Maps any caller-supplied reason to a known safe token. + public static string NormalizeTrigger(string? trigger) + { + if (string.IsNullOrWhiteSpace(trigger)) return UnknownTrigger; + return KnownTriggers.Contains(trigger.Trim()) ? trigger.Trim() : UnknownTrigger; + } + + /// + /// Enumerates at most source items and enforces the + /// per-frame and total character budgets. Stops pulling from the source as soon as a + /// budget is met, so the whole sequence is never materialized. + /// + public static List CollectFrames( + IEnumerable? frames, + int maxFrames = DefaultMaxFrames, + int maxCharsPerFrame = DefaultMaxCharsPerFrame, + int maxTotalChars = DefaultMaxTotalFrameChars) + { + var result = new List(); + if (frames is null) return result; + var frameLimit = maxFrames < 0 ? 0 : maxFrames; + var perLine = maxCharsPerFrame < 0 ? 0 : maxCharsPerFrame; + var total = maxTotalChars < 0 ? 0 : maxTotalChars; + if (frameLimit == 0 || total == 0) return result; + + var used = 0; + using var enumerator = frames.GetEnumerator(); + // Check the budget BEFORE advancing so the source is never pulled beyond the cap. + while (result.Count < frameLimit && used < total && enumerator.MoveNext()) + { + var line = enumerator.Current ?? string.Empty; + if (line.Length > perLine) line = line.Substring(0, perLine); + var remaining = total - used; + if (line.Length > remaining) line = line.Substring(0, remaining); + result.Add(line); + used += line.Length; + } + return result; + } + + public static string Format( + string trigger, + DateTimeOffset whenUtc, + IReadOnlyList frames, + int maxFrames = DefaultMaxFrames, + int maxCharsPerFrame = DefaultMaxCharsPerFrame, + int maxTotalChars = DefaultMaxTotalFrameChars) + { + var bounded = CollectFrames(frames, maxFrames, maxCharsPerFrame, maxTotalChars); + var sb = new StringBuilder(); + sb.Append("openclaw-hang-capture/v1\n"); + sb.Append("trigger=").Append(NormalizeTrigger(trigger)).Append('\n'); + sb.Append("utc=").Append(whenUtc.UtcDateTime.ToString("o", CultureInfo.InvariantCulture)).Append('\n'); + sb.Append("frames=").Append(bounded.Count).Append('\n'); + sb.Append("redacted=").Append(bounded.Count(IsSensitiveLine)).Append('\n'); + foreach (var line in bounded) + sb.Append(IsSensitiveLine(line) ? "[redacted]" : line).Append('\n'); + return sb.ToString(); + } + + /// + /// Writes a capture using exclusive creation under a second-resolution name so two + /// captures in the same second never overwrite each other; an existing capture is + /// always retained. Returns the path actually created. + /// + public static string WriteExclusive(string directory, string text, DateTimeOffset nowUtc, int maxNameAttempts = 1000) + { + if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("directory required", nameof(directory)); + Directory.CreateDirectory(directory); + var stamp = nowUtc.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture); + var payload = text ?? string.Empty; + var attempts = maxNameAttempts < 1 ? 1 : maxNameAttempts; + + for (var attempt = 0; attempt < attempts; attempt++) + { + var name = attempt == 0 ? "hang-" + stamp + ".txt" : "hang-" + stamp + "-" + attempt.ToString("D3", CultureInfo.InvariantCulture) + ".txt"; + if (TryCreateExclusive(Path.Combine(directory, name), payload, out var created)) + return created; + } + + // Every numeric candidate collided: fall back to a GUID name, still created exclusively. + var guidName = "hang-" + stamp + "-" + Guid.NewGuid().ToString("N") + ".txt"; + if (TryCreateExclusive(Path.Combine(directory, guidName), payload, out var guidCreated)) + return guidCreated; + + throw new IOException("Could not create a unique hang capture in " + directory); + } + + /// + /// Creates exclusively and writes the payload. + /// Returns false ONLY for a genuine name collision, in which case the existing capture + /// is left untouched. Creation or write failures for any other reason propagate, and a + /// partial capture left by a failed write is retained rather than silently retried. + /// + private static bool TryCreateExclusive(string path, string payload, out string createdPath) + { + createdPath = path; + FileStream stream; + try + { + stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + } + catch (IOException) when (File.Exists(path)) + { + // Name collision only: never overwrite an existing capture. + return false; + } + + // Creation succeeded. A write/flush failure now propagates (it is not reported as a + // name collision) and the partial capture stays on disk for inspection. + using (stream) + using (var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) + { + writer.Write(payload); + writer.Flush(); + } + return true; + } +} diff --git a/src/OpenClaw.Shared/HistoryPagingBudget.cs b/src/OpenClaw.Shared/HistoryPagingBudget.cs new file mode 100644 index 0000000000..4884415d32 --- /dev/null +++ b/src/OpenClaw.Shared/HistoryPagingBudget.cs @@ -0,0 +1,28 @@ +using System; + +namespace OpenClaw.Shared; + +/// +/// U1 companion reliability: bounded paging budget for history reconstruction. +/// Pure policy so the numbers can be unit-tested without the UI: how many items to +/// request per page, how many items may be folded per UI tick, and whether another +/// page is required. Keeping this out of the UI thread logic is what makes the +/// rebuild bounded and cancellable. +/// +internal static class HistoryPagingBudget +{ + public const int DefaultPageSize = 200; + public const int DefaultItemsPerTick = 250; + + /// Rows to request for the next page (0 when nothing remains). + public static int NextPageSize(int remaining, int pageSize = DefaultPageSize) + => remaining <= 0 || pageSize <= 0 ? 0 : Math.Min(remaining, pageSize); + + /// Rows that may be folded during this UI tick (0 when nothing pending). + public static int ItemsThisTick(int pending, int budget = DefaultItemsPerTick) + => pending <= 0 || budget <= 0 ? 0 : Math.Min(pending, budget); + + /// True when a full page came back, so paging should continue. + public static bool ShouldContinuePaging(int fetched, int pageSize) + => fetched > 0 && pageSize > 0 && fetched >= pageSize; +} diff --git a/src/OpenClaw.Shared/IFileSystem.cs b/src/OpenClaw.Shared/IFileSystem.cs index 38b228c918..752fb74e68 100644 --- a/src/OpenClaw.Shared/IFileSystem.cs +++ b/src/OpenClaw.Shared/IFileSystem.cs @@ -13,6 +13,9 @@ public interface IFileSystem void CopyFile(string source, string destination, bool overwrite); void DeleteFile(string path); void MoveFile(string source, string destination, bool overwrite) => File.Move(source, destination, overwrite); + + /// Length in bytes, or -1 when the implementation cannot report it (bounded-read guard). + long GetFileLength(string path) => -1; } /// @@ -32,4 +35,5 @@ public void CopyFile(string source, string destination, bool overwrite) => File.Copy(source, destination, overwrite); public void DeleteFile(string path) => File.Delete(path); public void MoveFile(string source, string destination, bool overwrite) => File.Move(source, destination, overwrite); + public long GetFileLength(string path) => new FileInfo(path).Length; } diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index 1a1e3ca271..5b2b9f6e57 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -44,6 +44,16 @@ event EventHandler? SelfProfileChanged { add { } remove { } } string? AuthenticatedSigningDeviceId => null; IReadOnlyList GrantedOperatorScopes { get; } bool IsConnectedToGateway { get; } + /// True while the last session-catalog refresh was withheld (partial/failed/empty); a retry is required. + /// Default false so non-catalog implementers (e.g. test doubles) stay source-compatible. + bool SessionCatalogRetryRequired => false; + + /// True while a bounded catalog acquisition is in progress. Default false. + bool SessionCatalogAcquisitionInProgress => false; + + /// Raised after an accepted catalog-refresh transition (retry needed or cleared). + /// Default no-op so non-catalog implementers stay source-compatible. + event EventHandler? SessionCatalogRefreshStateChanged { add { } remove { } } /// Canonical main session key resolved from hello-ok; null until handshake. string? MainSessionKey { get; } /// True once the hello-ok handshake has been processed. @@ -73,6 +83,14 @@ event EventHandler? SelfProfileChanged { add { } remove { } } /// Task RequestChatHistoryAsync(string? sessionKey = null, int timeoutMs = 15000) => Task.FromException(new NotSupportedException("chat.history is not supported by this gateway client.")); + + /// Bounded chat.history page (source-grounded offset/limit/maxBytes). Default: unsupported. + Task RequestChatHistoryPageAsync( + string? sessionKey, + ChatHistoryPageOptions options, + int timeoutMs = 15000, + System.Threading.CancellationToken cancellationToken = default) + => Task.FromException(new NotSupportedException("chat.history paging is not supported by this gateway client.")); Task CheckHealthAsync(); Task RequestSessionsAsync(string? agentId = null); Task RequestUsageAsync(); diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index c683d2ca1e..9463362e20 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -1867,6 +1867,12 @@ public static bool IsSilentAssistantDirective(string? role, string? text) => /// Gateway-assigned unique message ID from the __openclaw.id field. public string? OpenClawId { get; set; } + /// + /// Optional PROJECTED/DISPLAY item identity from the wire openclawStreamFallback.itemId when it is a + /// string. Kept SEPARATE from the raw ; ordinary messages leave this null. + /// + public string? OpenClawDisplayItemId { get; set; } + /// Monotonic sequence number within the session from __openclaw.seq. public int? OpenClawSeq { get; set; } @@ -1972,6 +1978,88 @@ public sealed class ChatMediaContentInfo /// reasoning-flagged payloads, and oversized messages) so consumers can /// render the messages directly. /// +/// +/// Typed, source-grounded chat.history page request options. Only the fields the installed handler +/// accepts are sent; offset/maxBytes are omitted when null so the exact no-offset branch is used. +/// Cursor is delta synchronization and is deliberately NOT modelled here as old-history paging. +/// +public sealed record ChatHistoryPageOptions(int Limit, int? MaxBytes = null, int? Offset = null); + +/// A bounded chat.history page plus the handler's paging metadata. +public sealed record GatewayChatHistoryPage( + string SessionKey, + string? SessionId, + IReadOnlyList Messages, + bool HasMore, + int? NextOffset, + int? ResponseOffset, + int? Total, + // WIRE signals only. completeSnapshot is set by the source ONLY for a complete CLI import; an ordinary + // paginated page OMITS it by design, so a missing flag is NOT evidence of omission. Actual loss is reported + // separately via the omission object (omittedCount/normalizedBytes), emitted only when the count is > 0. + bool? CompleteSnapshot = null, + int? OmittedCount = null, + long? NormalizedBytes = null, + // SOURCE-BACKED budget provenance: the ACTUAL producer sets this ONLY for a complete CLI import, whose + // legitimate bound is the source GLOBAL budget (not the requested envelope). It is provenance, NOT a + // licence to ignore budgets in general - an arbitrary omission alone is never accepted as such. + bool CompleteCliImport = false) +{ + /// + /// Source-grounded completeness state. Ordinary paginated pages are NOT partial: only an ACTUAL reported + /// omission (or a malformed/legacy shape) downgrades the page. + /// + public ChatHistoryPageCompleteness Completeness + { + get + { + if (OmittedCount is > 0) return ChatHistoryPageCompleteness.ReportedLoss; + if (CompleteSnapshot == true) return ChatHistoryPageCompleteness.SnapshotExplicitComplete; + // More pages remain: pagination is known and incomplete. + if (HasMore || NextOffset is not null) + return ChatHistoryPageCompleteness.PaginatedNotYetComplete; + // A valid ORDINARY terminal page: the source omits completeSnapshot for every non-CLI page, so a + // missing flag is NOT evidence of omission. The handler's OWN pagination metadata (hasMore, + // totalMessages, offset) proves this is a KNOWN ordinary page, never a legacy/unknown shape. + if (CompleteSnapshot == false || ResponseOffset is not null || Total is not null) + return ChatHistoryPageCompleteness.OrdinaryTerminalPage; + return ChatHistoryPageCompleteness.UnknownLegacy; + } + } + + /// True only when the source EXPLICITLY proved snapshot completeness on the wire. + public bool ContentComplete => Completeness == ChatHistoryPageCompleteness.SnapshotExplicitComplete; + + /// True only when the source ACTUALLY reported lost content (never a mere missing flag). + public bool ContentLossReported => Completeness == ChatHistoryPageCompleteness.ReportedLoss; +} + +/// Source-grounded completeness of an admitted history page. +public enum ChatHistoryPageCompleteness +{ + /// No completeness evidence and no pagination markers: legacy/unknown shape. + UnknownLegacy, + /// A normal paginated page that is not yet complete (more pages remain). + PaginatedNotYetComplete, + /// A valid ORDINARY terminal page (pagination ended, snapshot completeness not claimed). Not loss. + OrdinaryTerminalPage, + /// The source explicitly marked the snapshot complete on the wire. + SnapshotExplicitComplete, + /// The source actually reported omitted content (omittedCount > 0). + ReportedLoss, +} + +/// +/// Raised when a bounded chat.history page response cannot be admitted: the payload is not an object, +/// required metadata (sessionKey echo, messages array, hasMore, totalMessages) is missing or ill-typed, +/// or the paging progression is inconsistent. Callers must surface this as an unsupported/retry state +/// and must never present an unknown window as complete history. +/// +public sealed class ChatHistoryPageException : Exception +{ + public ChatHistoryPageException(string message) : base(message) { } +} + public class ChatHistoryInfo { /// Immutable session UUID assigned by the gateway. diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 308b5f951d..eaf20e724a 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -41,11 +41,46 @@ public partial class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatew // Tracked state private readonly Dictionary _sessions = new(); + private readonly SessionCatalogRefreshState _catalogRefreshState = new(); private ThinkingContext? _sessionThinkingDefaults; private GatewayUsageInfo? _usage; private GatewayUsageStatusInfo? _usageStatus; private GatewayCostUsageInfo? _usageCost; private readonly PendingRequestRegistry _pendingRequests = new(); + /// Raised after an accepted catalog-refresh transition (retry needed or cleared), outside locks. + public event EventHandler? SessionCatalogRefreshStateChanged; + + /// True while a bounded catalog acquisition is in progress (begin..terminal). + public bool SessionCatalogAcquisitionInProgress => Volatile.Read(ref _sessionsPageSet) is not null; + + private void RaiseCatalogRefreshStateChanged() => + SessionCatalogRefreshStateChanged?.Invoke(this, new SessionCatalogRefreshStateChangedEventArgs + { + RetryRequired = _catalogRefreshState.RetryRequired, + AcquisitionInProgress = Volatile.Read(ref _sessionsPageSet) is not null + }); + + private readonly object _sessionsAcquisitionLock = new(); + private SessionCatalogPageSet? _sessionsPageSet; + private CancellationTokenSource? _sessionsAcquisitionCts; + private long _catalogRefreshGeneration; + + /// Immutable per-request acquisition context correlated back with the response. + private sealed class SessionCatalogRequestContext(long generation, string? scope, int requestedOffset, SessionCatalogPageSet pageSet) + { + public long Generation { get; } = generation; + public string? Scope { get; } = scope; + public int RequestedOffset { get; } = requestedOffset; + public SessionCatalogPageSet PageSet { get; } = pageSet; + } + + /// Result of admitting one sessions payload (admitted keys + the committed snapshot). + private readonly record struct SessionParseResult(HashSet AdmittedKeys, SessionInfo[]? Snapshot); + + /// Test-only hook invoked inside the acquisition lock just before admission (barrier controls). + internal Action? TestBeforeAdmissionHook { get; set; } + + private Func _acquisitionDelay = static (delay, token) => Task.Delay(delay, token); private readonly object _sessionsLock = new(); private readonly DeviceIdentity _deviceIdentity; private sealed record SigningIdentity(string RequestId, long Generation, string DeviceId); @@ -199,6 +234,7 @@ protected override Task OnConnectedAsync() Volatile.Write(ref _handshakeConnectionGeneration, 0); Volatile.Write(ref _hasHandshakeSnapshot, false); _pendingRequests.OpenConnection(); + InvalidateSessionAcquisition(); ResetUnsupportedMethodFlags(); RaiseTransportConnected(); return Task.CompletedTask; @@ -442,6 +478,7 @@ public async Task DisconnectAsync() } } _pendingRequests.Drain(); + InvalidateSessionAcquisition(); RaiseStatusChanged(ConnectionStatus.Disconnected); _logger.Info("Disconnected"); } @@ -577,6 +614,252 @@ public async Task RequestChatHistoryAsync(string? sessionKey = return ParseChatHistory(payload, effectiveSessionKey); } + /// + /// Source-grounded bounded history page: sends limit (+ optional maxBytes/offset) exactly as the + /// installed handler accepts them and returns the response's paging metadata. The legacy + /// RequestChatHistoryAsync path is untouched (no offset) and is NOT claimed to be a complete page. + /// + public async Task RequestChatHistoryPageAsync( + string? sessionKey, + ChatHistoryPageOptions options, + int timeoutMs = 15000, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + if (options.Limit < 1) throw new ArgumentOutOfRangeException(nameof(options), "limit must be >= 1"); + if (options.Limit > 1000) throw new ArgumentOutOfRangeException(nameof(options), "limit must be <= 1000 (installed handler maximum page limit)"); + if (options.MaxBytes is < 1024) throw new ArgumentOutOfRangeException(nameof(options), "maxBytes must be >= 1024 when present"); + if (options.Offset is < 0) throw new ArgumentOutOfRangeException(nameof(options), "offset must be >= 0 when present"); + + cancellationToken.ThrowIfCancellationRequested(); + var effectiveSessionKey = ResolveEffectiveSessionKey( + sessionKey, Volatile.Read(ref _mainSessionKey), "chat.history"); + + object parameters = + options.Offset is null && options.MaxBytes is null + ? new { sessionKey = effectiveSessionKey, limit = options.Limit } + : options.Offset is null + ? new { sessionKey = effectiveSessionKey, limit = options.Limit, maxBytes = options.MaxBytes!.Value } + : options.MaxBytes is null + ? new { sessionKey = effectiveSessionKey, limit = options.Limit, offset = options.Offset!.Value } + : new { sessionKey = effectiveSessionKey, limit = options.Limit, maxBytes = options.MaxBytes!.Value, offset = options.Offset!.Value }; + + var payload = await SendWizardRequestAsync("chat.history", parameters, timeoutMs); + cancellationToken.ThrowIfCancellationRequested(); + return ParseChatHistoryPage(payload, effectiveSessionKey, options); + } + + /// + /// Strict, source-grounded admission of a bounded chat.history page. The installed handler emits + /// totalMessages and treats offset 0 as the tail. Any missing/ill-typed metadata or inconsistent + /// progression throws so callers surface an + /// unsupported/retry state instead of silently presenting an unknown window as complete history. + /// + private static GatewayChatHistoryPage ParseChatHistoryPage(JsonElement payload, string sessionKey, ChatHistoryPageOptions options) + { + var requestedOffset = options.Offset; + if (payload.ValueKind != JsonValueKind.Object) + throw new ChatHistoryPageException("chat.history page response was not a JSON object; completeness is unknown."); + + // The handler echoes the request's sessionKey; require an exact match (no alias/canonicalization). + if (!payload.TryGetProperty("sessionKey", out var keyProp) || keyProp.ValueKind != JsonValueKind.String) + throw new ChatHistoryPageException("chat.history page response omitted a string sessionKey echo."); + var echoedKey = keyProp.GetString(); + if (!string.Equals(echoedKey, sessionKey, StringComparison.Ordinal)) + throw new ChatHistoryPageException($"chat.history page echoed sessionKey '{echoedKey}' for requested '{sessionKey}'."); + + if (!payload.TryGetProperty("messages", out var msgsProp) || msgsProp.ValueKind != JsonValueKind.Array) + throw new ChatHistoryPageException("chat.history page response omitted the messages array."); + + // REQUESTED CONTENT BUDGET: the admitted page must honour the request's OWN bounds. The requested + // MESSAGE-ARRAY budget (maxBytes) is distinct from the transport-envelope cap the socket applies, so + // this guards the reply CONTENT, not the frame. A page that ignores the requested row count or message + // byte budget is NOT a valid page: reject it HERE (before any history is constructed or replayed) so + // callers surface an unsupported/retry state instead of presenting over-budget history. No silent + // truncation and no unbounded fallback. + // REQUESTED ROW BUDGET with SOURCE-SEMANTICS: the installed handler caps PROJECTED messages by the raw + // requested count, but its cap PRESERVES same-sequence boundary siblings (capOffset...): a page may exceed + // the raw limit only by messages that share the boundary raw __openclaw.seq group. An unconditional + // count<=limit guard rejects a VALID gateway response; an unconditional pass would admit an ignored limit. + // So: allow the overflow ONLY when every extra (older) message shares the boundary seq; otherwise reject. + // WIRE AUTHORITY: the wire completeSnapshot flag is the ONLY completeness signal. An internal + // completeCliImport marker is NOT wire authority and is never invented or accepted from the payload. + bool? completeSnapshot = null; + if (payload.TryGetProperty("completeSnapshot", out var completeSnapshotProp)) + { + if (completeSnapshotProp.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new ChatHistoryPageException("chat.history page completeSnapshot is not a boolean."); + completeSnapshot = completeSnapshotProp.GetBoolean(); + } + + var admittedCount = msgsProp.GetArrayLength(); + // FINITE WIRE BOUND (not an invented row cap): the completeCLI merge can legitimately emit more projected + // rows than the requested raw limit, bounded only by the hard INBOUND wire cap (32 MiB, mirroring the + // socket guard). No unbounded allocation is implied: the payload was already bounded on receive. + var messageArray = msgsProp.GetRawText(); + if (messageArray.Length > MaxWirePageChars) + throw new ChatHistoryPageException( + $"chat.history page payload exceeds the {MaxWirePageChars}-char wire bound."); + // ACTUAL OMISSION (source emits this object only when content was really omitted). Strict, typed, bounded. + int? omittedCount = null; + long? normalizedBytes = null; + if (payload.TryGetProperty("omission", out var omissionProp) && omissionProp.ValueKind != JsonValueKind.Null) + { + if (omissionProp.ValueKind != JsonValueKind.Object) + throw new ChatHistoryPageException("chat.history page omission is not an object."); + if (omissionProp.TryGetProperty("omittedCount", out var omittedCountProp)) + { + if (omittedCountProp.ValueKind != JsonValueKind.Number || !omittedCountProp.TryGetInt32(out var oc) || oc < 0) + throw new ChatHistoryPageException("chat.history page omission.omittedCount is not a non-negative integer."); + omittedCount = oc; + } + if (omissionProp.TryGetProperty("normalizedBytes", out var normalizedBytesProp)) + { + if (normalizedBytesProp.ValueKind != JsonValueKind.Number || !normalizedBytesProp.TryGetInt64(out var nb) || nb < 0) + throw new ChatHistoryPageException("chat.history page omission.normalizedBytes is not a non-negative integer."); + normalizedBytes = nb; + } + } + + // SOURCE-BACKED budget provenance (strict boolean). This is the ACTUAL producer's explicit signal that + // the page is a complete-CLI projection bounded by the source GLOBAL budget; it is NOT a licence to + // ignore budgets in general (an arbitrary omission alone is never accepted as such). + var completeCliImport = false; + if (payload.TryGetProperty("completeCliImport", out var completeCliImportProp)) + { + if (completeCliImportProp.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new ChatHistoryPageException("chat.history page completeCliImport is not a boolean."); + completeCliImport = completeCliImportProp.GetBoolean(); + } + + // CONTRADICTION: an explicit complete snapshot cannot also claim a reported omission. + if (completeSnapshot == true && omittedCount is > 0) + throw new ChatHistoryPageException("chat.history page reported completeSnapshot WITH a reported omission; contradictory."); + + if (completeSnapshot != true && !completeCliImport && admittedCount > options.Limit && !IsProjectedSiblingOverflowAllowed(msgsProp, options.Limit)) + throw new ChatHistoryPageException( + $"chat.history page admitted {admittedCount} messages for a requested limit of {options.Limit} without a same-sequence sibling boundary."); + // REQUESTED CONTENT BUDGET: applies to a NON-complete page. A completeSnapshot page is bounded by the + // SOURCE GLOBAL budget instead (completeCLI can legitimately exceed the requested envelope budget), so the + // requested byte budget must not reject it. This is NOT a no-op guard: non-complete pages stay bounded. + if (completeSnapshot != true && !completeCliImport && options.MaxBytes is int requestedMaxBytes) + { + var messageArrayBytes = System.Text.Encoding.UTF8.GetByteCount(messageArray); + if (messageArrayBytes > requestedMaxBytes) + throw new ChatHistoryPageException( + $"chat.history page message array is {messageArrayBytes} bytes for a requested maxBytes of {requestedMaxBytes}."); + } + + // hasMore must be a real boolean; a missing/ill-typed value must never read as "complete". + if (!payload.TryGetProperty("hasMore", out var hasMoreProp) + || hasMoreProp.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new ChatHistoryPageException("chat.history page response has no boolean hasMore."); + var hasMore = hasMoreProp.GetBoolean(); + + // totalMessages must be a non-negative integer (the handler field, not "total"). + if (!payload.TryGetProperty("totalMessages", out var totalProp) + || totalProp.ValueKind != JsonValueKind.Number || !totalProp.TryGetInt32(out var total) || total < 0) + throw new ChatHistoryPageException("chat.history page response has no non-negative integer totalMessages."); + + // Response offset: when a page was requested it must equal the request; a no-offset (tail) + // request may legitimately omit it. + int? responseOffset = null; + if (payload.TryGetProperty("offset", out var offsetProp) && offsetProp.ValueKind != JsonValueKind.Null) + { + if (offsetProp.ValueKind != JsonValueKind.Number || !offsetProp.TryGetInt32(out var offsetValue) || offsetValue < 0) + throw new ChatHistoryPageException("chat.history page response offset is not a non-negative integer."); + responseOffset = offsetValue; + } + if (requestedOffset is int requested && responseOffset != requested) + throw new ChatHistoryPageException( + $"chat.history page returned offset {responseOffset?.ToString() ?? ""} for requested offset {requested}."); + + // nextOffset: required, non-negative, strictly past the effective offset and below totalMessages + // when more pages remain; legitimately omitted (or null) only on exhaustion. + int? nextOffset = null; + var hasNextProp = payload.TryGetProperty("nextOffset", out var nextProp); + if (hasMore) + { + var effective = requestedOffset ?? 0; + if (!hasNextProp || nextProp.ValueKind != JsonValueKind.Number + || !nextProp.TryGetInt32(out var nextValue) || nextValue < 0) + throw new ChatHistoryPageException("chat.history reported hasMore without a non-negative integer nextOffset."); + if (nextValue <= effective || nextValue >= total) + throw new ChatHistoryPageException( + $"chat.history nextOffset {nextValue} is not past {effective} and below totalMessages {total}."); + nextOffset = nextValue; + } + else if (hasNextProp && nextProp.ValueKind != JsonValueKind.Null) + { + throw new ChatHistoryPageException("chat.history reported completion but still returned a nextOffset."); + } + + // CONTRADICTION (checked after hasMore is known): complete snapshot WITH more pages is impossible. + if (completeSnapshot == true && hasMore) + throw new ChatHistoryPageException("chat.history page reported completeSnapshot WITH hasMore; contradictory."); + + var info = ParseChatHistory(payload, sessionKey); + return new GatewayChatHistoryPage( + sessionKey, info.SessionId, info.Messages, hasMore, nextOffset, responseOffset, total, + completeSnapshot, omittedCount, normalizedBytes, completeCliImport); + } + + /// + /// True when a page that EXCEEDS the requested raw limit is the source cap's same-sequence boundary-sibling + /// preservation: with start = count - limit, EVERY older message before start must share the boundary + /// message's positive __openclaw.seq. If the boundary has no seq (or a sibling differs), the overflow + /// is NOT justified and the page is rejected - so the generic ignored-limit rejection stays meaningful. + /// + private static bool IsProjectedSiblingOverflowAllowed(JsonElement messages, int limit) + { + var count = messages.GetArrayLength(); + if (count <= limit || limit < 1) return count <= limit; + var start = count - limit; + if (!TryReadChatHistoryMessageSeq(messages[start], out var boundarySeq)) return false; + for (var i = 0; i < start; i++) + { + if (!TryReadChatHistoryMessageSeq(messages[i], out var seq) || seq != boundarySeq) return false; + } + return true; + } + + /// Finite bound on an admitted page, independent of the requested limit and the global source cap. + /// Hard INBOUND wire bound for one page payload (mirrors the socket 32 MiB character guard). + private const int MaxWirePageChars = 32 * 1024 * 1024; + + /// Number.MAX_SAFE_INTEGER (2^53 - 1): the largest integral JS number the source treats as a safe seq. + private const double MaxSafeSeq = 9007199254740991d; + + /// + /// Reads a message __openclaw.seq under the ACTUAL source numeric contract: + /// typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0. A JSON number is evaluated + /// numerically (so 1, 1.0 and 1e0 all read as 1), a non-integral / non-finite / non-positive value is undefined, + /// and a value above Number.MAX_SAFE_INTEGER (e.g. 2^53) is undefined - NOT accepted because it fits an int64. + /// + private static bool TryReadChatHistoryMessageSeq(JsonElement message, out long seq) + { + seq = 0; + if (message.ValueKind != JsonValueKind.Object) return false; + if (!message.TryGetProperty("__openclaw", out var metadata) || metadata.ValueKind != JsonValueKind.Object) return false; + if (!metadata.TryGetProperty("seq", out var seqProperty) || seqProperty.ValueKind != JsonValueKind.Number) return false; + + double value; + try + { + value = seqProperty.GetDouble(); + } + catch (Exception) + { + return false; // not representable as a double => not a JS safe integer + } + + if (double.IsNaN(value) || double.IsInfinity(value)) return false; + if (value <= 0 || value > MaxSafeSeq) return false; + if (Math.Floor(value) != value) return false; // Number.isSafeInteger requires an integer + seq = (long)value; + return true; + } + /// /// Aborts an in-flight agent run. Per spec, partial assistant output may /// still be visible after the abort and is persisted into the transcript @@ -787,6 +1070,7 @@ private static ChatHistoryInfo ParseChatHistory(JsonElement payload, string sess State = "final", Ts = ts, OpenClawId = openClawMetadata.Id, + OpenClawDisplayItemId = ExtractOpenClawDisplayItemId(m), OpenClawSeq = openClawMetadata.Seq, OpenClawKind = openClawMetadata.Kind, CompactionTokensBefore = openClawMetadata.TokensBefore, @@ -1347,14 +1631,157 @@ private async Task SendResponseRequestAsync( } } + /// + /// U2: true when a session-catalog refresh returned no rows and was ignored to protect + /// the held catalog. The UI can surface a retry/error state; it must never blank the list. + /// + public bool SessionCatalogRetryRequired => _catalogRefreshState.RetryRequired; + + /// Number of ignored empty catalog refreshes since the last successful refresh. + public int IgnoredEmptyCatalogRefreshes => _catalogRefreshState.IgnoredEmptyRefreshes; + /// Request session list from gateway. public async Task RequestSessionsAsync(string? agentId = null) { if (_operatorReadScopeUnavailable) return; - if (!string.IsNullOrEmpty(agentId)) - await SendTrackedRequestAsync("sessions.list", new { agentId }); - else - await SendTrackedRequestAsync("sessions.list"); + // Start a bounded complete page-set acquisition. Each response is correlated back by + // request identity; the scope travels with every page so a scoped (filtered) response + // never deletes other agents' held sessions. Supersedes any in-flight acquisition. + SessionCatalogPageSet pageSet; + long generation; + lock (_sessionsAcquisitionLock) + { + generation = ++_catalogRefreshGeneration; + pageSet = new SessionCatalogPageSet(agentId, generation, Environment.TickCount64); + _sessionsPageSet = pageSet; + StartAcquisitionDeadlineLocked(pageSet, generation, null); + } + + // Send the explicit bounded initial window (limit + offset 0) with per-request context. + // Begin transition: an acquisition is now in progress. + RaiseCatalogRefreshStateChanged(); + + bool sent; + try + { + sent = await SendSessionsListPageAsync(agentId, offset: 0, generation, pageSet); + } + catch (Exception ex) + { + _logger.Warn($"[SESSIONS] initial page send threw: {ex.Message}"); + sent = false; + } + + if (!sent) + { + lock (_sessionsAcquisitionLock) + { + if (ReferenceEquals(_sessionsPageSet, pageSet)) + { + _catalogRefreshState.RecordIgnoredEmpty(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _logger.Warn("[SESSIONS] initial page send was not accepted; preserving held catalog and marking retry"); + CompleteAcquisitionLocked(pageSet); + } + } + // Terminal transition (send false or throw): acquisition ends promptly. + RaiseCatalogRefreshStateChanged(); + } + } + + /// Requests one bounded window of the session catalog for an in-flight acquisition. + private async Task SendSessionsListPageAsync(string? agentId, int offset, long generation, SessionCatalogPageSet pageSet) + { + object payload = string.IsNullOrEmpty(agentId) + ? new { limit = SessionCatalogPageSet.DefaultPageSize, offset } + : new { agentId, limit = SessionCatalogPageSet.DefaultPageSize, offset }; + var context = new SessionCatalogRequestContext(generation, agentId, offset, pageSet); + // Dependency seam: unit tests can supply the bool send result without a live socket. + if (_sessionsPageSender is not null) return await _sessionsPageSender(agentId, offset); + return await SendTrackedRequestAsync("sessions.list", payload, scope: agentId, context: context); + } + + /// Autonomous deadline. Caller must hold . + private void StartAcquisitionDeadlineLocked(SessionCatalogPageSet pageSet, long generation, TimeSpan? deadline) + { + var cts = new CancellationTokenSource(); + var previous = _sessionsAcquisitionCts; + _sessionsAcquisitionCts = cts; + previous?.Cancel(); + previous?.Dispose(); + var token = cts.Token; + var wait = deadline ?? SessionCatalogPageSet.DefaultDeadline; + _ = Task.Run(async () => + { + try { await _acquisitionDelay(wait, token).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + var changed = false; + lock (_sessionsAcquisitionLock) + { + if (ReferenceEquals(_sessionsPageSet, pageSet) && pageSet.Generation == _catalogRefreshGeneration) + { + _catalogRefreshState.RecordIgnoredEmpty(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _logger.Warn("[SESSIONS] page-set acquisition timed out with no response; marking retry"); + CompleteAcquisitionLocked(pageSet); + changed = true; + } + } + if (changed) RaiseCatalogRefreshStateChanged(); + }); + } + + /// Generation-safe completion. Caller must hold . + private void CompleteAcquisitionLocked(SessionCatalogPageSet pageSet) + { + if (!ReferenceEquals(_sessionsPageSet, pageSet)) return; + _sessionsPageSet = null; + var cts = _sessionsAcquisitionCts; + _sessionsAcquisitionCts = null; + cts?.Cancel(); + cts?.Dispose(); + } + + /// Invalidate any in-flight acquisition (disconnect/open/supersession). + private void InvalidateSessionAcquisition() + { + lock (_sessionsAcquisitionLock) + { + _catalogRefreshGeneration++; + _sessionsPageSet = null; + var cts = _sessionsAcquisitionCts; + _sessionsAcquisitionCts = null; + cts?.Cancel(); + cts?.Dispose(); + } + RaiseCatalogRefreshStateChanged(); + } + + private async Task SendSessionsListPageGuardedAsync(string? agentId, int offset, long generation, SessionCatalogPageSet pageSet) + { + bool sent; + try + { + sent = await SendSessionsListPageAsync(agentId, offset, generation, pageSet); + } + catch (Exception ex) + { + _logger.Warn($"[SESSIONS] continuation send threw at offset {offset}: {ex.Message}"); + sent = false; + } + + if (sent) return; + + var changed = false; + lock (_sessionsAcquisitionLock) + { + if (ReferenceEquals(_sessionsPageSet, pageSet)) + { + _catalogRefreshState.RecordIgnoredEmpty(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _logger.Warn($"[SESSIONS] continuation send not accepted at offset {offset}; preserving held catalog and marking retry"); + CompleteAcquisitionLocked(pageSet); + changed = true; + } + } + if (changed) RaiseCatalogRefreshStateChanged(); } /// Subscribe to session change events so the gateway pushes @@ -2254,7 +2681,7 @@ private ConnectCredential SelectConnectCredential() /// (SendConnectMessageAsync sends via SendRawAsync directly), so protocol /// frames are exempt by construction. /// - private async Task SendTrackedRequestAsync(string method, object? parameters = null) + private async Task SendTrackedRequestAsync(string method, object? parameters = null, string? scope = null, object? context = null) { if (!TryGetReadyConnectionGeneration(out var connectionGeneration)) { @@ -2263,7 +2690,7 @@ private async Task SendTrackedRequestAsync(string method, object? paramete } var requestId = Guid.NewGuid().ToString(); - var pending = _pendingRequests.RegisterTracked(requestId, method); + var pending = _pendingRequests.RegisterTracked(requestId, method, scope, context); try { var sent = await SendRawAsync( @@ -2286,11 +2713,11 @@ private async Task SendTrackedRequestAsync(string method, object? paramete } } - private async Task TrySendTrackedRequestAsync(string method, object? parameters = null) + private async Task TrySendTrackedRequestAsync(string method, object? parameters = null, string? scope = null, object? context = null) { try { - return await SendTrackedRequestAsync(method, parameters); + return await SendTrackedRequestAsync(method, parameters, scope, context); } catch (Exception ex) { @@ -2381,6 +2808,8 @@ private void HandleResponseForConnection( _pendingRequests.TryTake(requestId, out var pending); var requestMethod = pending?.Method; + var requestScope = pending?.Scope; + var requestContext = pending?.Context as SessionCatalogRequestContext; switch (pending?.Category) { @@ -2476,7 +2905,7 @@ HandshakeAuthorizationAsync is not null && return; } - if (!string.IsNullOrEmpty(requestMethod) && HandleKnownResponse(requestMethod!, payload)) + if (!string.IsNullOrEmpty(requestMethod) && HandleKnownResponse(requestMethod!, payload, requestScope, requestContext)) { return; } @@ -2642,7 +3071,7 @@ private bool TryStoreHandshakeDeviceToken(string role, string token, string[]? s } } - private bool HandleKnownResponse(string method, JsonElement payload) + private bool HandleKnownResponse(string method, JsonElement payload, string? requestScope = null, SessionCatalogRequestContext? requestContext = null) { switch (method) { @@ -2653,7 +3082,7 @@ private bool HandleKnownResponse(string method, JsonElement payload) return true; case "sessions.list": if (TryGetSessionsPayload(payload, out _)) - ParseSessions(payload); + HandleSessionsListResponse(payload, requestContext); return true; case "usage": ParseUsage(payload); @@ -4079,7 +4508,8 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) messageOpenClawMetadata.Kind ?? payloadOpenClawMetadata.Kind, messageOpenClawMetadata.TokensBefore ?? payloadOpenClawMetadata.TokensBefore, messageOpenClawMetadata.TokensAfter ?? payloadOpenClawMetadata.TokensAfter, - contentParts); + contentParts, + ExtractOpenClawDisplayItemId(message) ?? ExtractOpenClawDisplayItemId(payload)); if (role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase)) { @@ -4120,7 +4550,8 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) openClawMetadata.Kind, openClawMetadata.TokensBefore, openClawMetadata.TokensAfter, - projection.ContentParts); + projection.ContentParts, + ExtractOpenClawDisplayItemId(payload)); if (role == "assistant" && (string.IsNullOrWhiteSpace(state) || @@ -4200,7 +4631,8 @@ private void EmitChatMessageReceived( string? openClawKind = null, long? compactionTokensBefore = null, long? compactionTokensAfter = null, - IReadOnlyList? contentParts = null) + IReadOnlyList? contentParts = null, + string? openClawDisplayItemId = null) { if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return; @@ -4220,6 +4652,7 @@ private void EmitChatMessageReceived( ResponseTokens = responseTokens, ContextPercent = contextPct, OpenClawId = openClawId, + OpenClawDisplayItemId = openClawDisplayItemId, OpenClawSeq = openClawSeq, OpenClawKind = openClawKind, CompactionTokensBefore = compactionTokensBefore, @@ -4232,6 +4665,25 @@ private void EmitChatMessageReceived( } } + /// + /// Reads the optional projected/display item identity from openclawStreamFallback.itemId ONLY when the + /// nested value is a non-empty string; returns null otherwise. Separate from the raw __openclaw.id. + /// + private static string? ExtractOpenClawDisplayItemId(JsonElement node) + { + if (node.ValueKind != JsonValueKind.Object) + return null; + if (!node.TryGetProperty("openclawStreamFallback", out var fallback) || + fallback.ValueKind != JsonValueKind.Object) + return null; + if (fallback.TryGetProperty("itemId", out var itemId) && itemId.ValueKind == JsonValueKind.String) + { + var value = itemId.GetString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + return null; + } + private static ( string? Id, int? Seq, @@ -4428,30 +4880,200 @@ private void PublishGatewaySelf(GatewaySelfInfo info) GatewaySelfUpdated?.Invoke(this, info); } - private void ParseSessions(JsonElement sessions) + /// + /// Reads an explicit authoritative session count from a sessions.list payload. + /// Returns null when the payload does not declare one (treated as non-authoritative + /// so an empty refresh cannot silently clear a held catalog). + /// + private static int? TryReadAuthoritativeSessionCount(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + if (payload.ValueKind == JsonValueKind.Object + && payload.TryGetProperty("count", out var count) + && count.ValueKind == JsonValueKind.Number + && count.TryGetInt32(out var value) + && value >= 0) + { + return value; + } + if (payload.TryGetProperty("sessions", out var inner) && inner.ValueKind == JsonValueKind.Object) + return TryReadAuthoritativeSessionCount(inner); + return null; + } + + /// + /// Handles a sessions.list response. With an acquisition context, the response is validated + /// (context, bounds, keys, metadata) BEFORE any catalog mutation; stale/superseded/foreign + /// responses are rejected outright. Deletion is permitted only for the first page of a set + /// (a single complete page) or the legacy no-acquisition path; a tail page never prunes. + /// + private void HandleSessionsListResponse(JsonElement payload, SessionCatalogRequestContext? context) { + if (context is null) + { + // Legacy / event-driven refresh with no acquisition context. + ParseSessions(payload, null, allowDeletion: true); + RaiseCatalogRefreshStateChanged(); + return; + } + + bool allowDeletion; + PageSetDecision decision; + SessionInfo[]? snapshot = null; + var rejected = false; + // Validate context + bounds/keys/metadata AND commit the catalog mutation under the one + // acquisition lock, so a supersession/disconnect cannot interleave between validation and + // admission. Events and sends are published only after the lock is released. + lock (_sessionsAcquisitionLock) + { + // Reject stale/superseded/foreign responses BEFORE any mutation. + if (context.Generation != _catalogRefreshGeneration) + { + Interlocked.Increment(ref _testStaleResponseRejections); + return; + } + if (!ReferenceEquals(context.PageSet, _sessionsPageSet)) + { + Interlocked.Increment(ref _testStaleResponseRejections); + return; + } + if (!string.Equals(context.Scope, context.PageSet.Scope, StringComparison.Ordinal)) return; + if (context.PageSet.Decision != PageSetDecision.Continue) return; + + var scan = SessionCatalogAuthority.ScanPage(payload); + var refresh = SessionCatalogAuthority.Read(payload, scan.AdmittedKeys.Count); + var bytes = System.Text.Encoding.UTF8.GetByteCount(payload.GetRawText()); + allowDeletion = context.RequestedOffset == 0 && context.PageSet.Pages == 0; + + decision = context.PageSet.Accept(refresh, scan.AdmittedKeys, context.RequestedOffset, bytes, Environment.TickCount64); + + if (decision == PageSetDecision.Failed) + { + // A failed/oversized/late page must not update the held snapshot. + _catalogRefreshState.RecordIgnoredEmpty(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _logger.Warn($"[SESSIONS] page-set acquisition rejected ({context.PageSet.Failure}); preserving {_sessions.Count} held session(s)"); + CompleteAcquisitionLocked(context.PageSet); + rejected = true; + } + else + { + // Barrier hook (tests) simulates a supersession landing between validation and + // admission; the re-check below still runs under the same lock as the commit. + TestBeforeAdmissionHook?.Invoke(); + if (context.Generation != _catalogRefreshGeneration || !ReferenceEquals(context.PageSet, _sessionsPageSet)) + return; // superseded in the gap -> do not admit; the newer authority owns the catalog + + // Commit the catalog mutation atomically with the acquisition authority. + var parsed = ParseSessions(payload, context.Scope, allowDeletion, raiseEvents: false); + snapshot = parsed.Snapshot; + } + } + + // Publish events/state only after the lock is released. A rejected page may not have + // changed the session collection, so the state-change event still fires (never an empty + // session event). + if (rejected) + { + RaiseCatalogRefreshStateChanged(); + return; + } + if (snapshot is not null) SessionsUpdated?.Invoke(this, snapshot); + + if (decision == PageSetDecision.Continue && context.PageSet.NextOffset is int next) + { + // Still in progress: the next page continues the same acquisition. + RaiseCatalogRefreshStateChanged(); + _ = SendSessionsListPageGuardedAsync(context.Scope, next, context.Generation, context.PageSet); + return; + } + + lock (_sessionsAcquisitionLock) + { + CompleteAcquisitionLocked(context.PageSet); + } + // Terminal transition: the acquisition has ended (retry re-enabled by the UI). + RaiseCatalogRefreshStateChanged(); + } + + // --- Internal seams for deterministic production-wiring controls (tests only). --- + internal void TestSetAcquisition(long generation, SessionCatalogPageSet set) + { + Volatile.Write(ref _catalogRefreshGeneration, generation); + _sessionsPageSet = set; + } + + internal SessionCatalogPageSet? TestAcquisition => _sessionsPageSet; + + internal void TestFeedSessionsList(JsonElement payload, string? scope, int requestedOffset, long generation, SessionCatalogPageSet pageSet) + => HandleSessionsListResponse(payload, new SessionCatalogRequestContext(generation, scope, requestedOffset, pageSet)); + + /// Test-only: drives the production legacy (no-acquisition) sessions path. + internal void TestFeedLegacySessionsList(JsonElement payload) => HandleSessionsListResponse(payload, null); + + internal void TestCompleteAcquisition(SessionCatalogPageSet set) + { + lock (_sessionsAcquisitionLock) { CompleteAcquisitionLocked(set); } + } + + internal void TestInvalidateAcquisition() => InvalidateSessionAcquisition(); + + /// Test-only sender seam: supplies the sessions.list page send result without a live socket. + internal Func>? _sessionsPageSender; + + internal void TestSetPageSender(Func> sender) => _sessionsPageSender = sender; + + internal void TestSetDelay(Func delay) => _acquisitionDelay = delay; + + internal void TestInstallAcquisitionWithDeadline(SessionCatalogPageSet set, long generation, TimeSpan deadline) + { + lock (_sessionsAcquisitionLock) + { + _catalogRefreshGeneration = generation; + _sessionsPageSet = set; + StartAcquisitionDeadlineLocked(set, generation, deadline); + } + } + + internal int TestCatalogCount => _sessions.Count; + + private int _testStaleResponseRejections; + + /// Deterministic barrier: counts responses rejected as stale/superseded before any mutation. + internal int TestStaleResponseRejections => Volatile.Read(ref _testStaleResponseRejections); + + private SessionParseResult ParseSessions(JsonElement sessions, string? requestedAgentScope = null, bool allowDeletion = true, bool raiseEvents = true) + { + var incomingKeys = new HashSet(); + SessionInfo[]? snapshot = null; try { - SessionInfo[] snapshot; lock (_sessionsLock) { var envelope = sessions; if (envelope.ValueKind == JsonValueKind.Object && envelope.TryGetProperty("sessions", out var rows)) sessions = rows; - _sessionThinkingDefaults = envelope.ValueKind == JsonValueKind.Object + // Parse into STAGED candidates first; live tracked state is only mutated after a + // fully successful parse, so a malformed row/field cannot leave partial updates. + var stagedDefaults = envelope.ValueKind == JsonValueKind.Object && envelope.TryGetProperty("defaults", out var defaults) && defaults.ValueKind == JsonValueKind.Object ? ThinkingMetadata.MergeSession(defaults, _sessionThinkingDefaults) : null; + var staged = new Dictionary(StringComparer.Ordinal); + SessionInfo Stage(string key) + { + if (staged.TryGetValue(key, out var existing)) return existing; + var copy = _sessions.TryGetValue(key, out var held) ? held.Clone() : new SessionInfo { Key = key }; + staged[key] = copy; + return copy; + } // Merge instead of clear — collect incoming keys, update/add, then remove absent - var incomingKeys = new HashSet(); - if (sessions.ValueKind == JsonValueKind.Array) { foreach (var item in sessions.EnumerateArray()) { - var key = ParseSessionItem(item); + var key = ParseSessionItem(item, Stage, stagedDefaults); if (key != null) incomingKeys.Add(key); } } @@ -4483,11 +5105,8 @@ private void ParseSessions(JsonElement sessions) continue; } - // Update or create session - if (!_sessions.TryGetValue(sessionKey, out var session)) - { - session = new SessionInfo { Key = sessionKey }; - } + // Update or create session (staged, not yet committed) + var session = Stage(sessionKey); UpdateSessionMainStatus(session, sessionKey, item); @@ -4500,19 +5119,47 @@ private void ParseSessions(JsonElement sessions) session.Status = item.GetString() ?? ""; } - session.ThinkingDefaults = _sessionThinkingDefaults; - _sessions[sessionKey] = session; + session.ThinkingDefaults = stagedDefaults; incomingKeys.Add(sessionKey); } } - // Remove sessions no longer present in the gateway response + // P2: never replace a non-empty catalog with an empty one. + // A transient/failed sessions.list can return no rows; deleting every + // held session in that case is what made sessions (for example the + // helper session) disappear until the next successful refresh. + // An explicit authoritative count in the payload still wins. + // Phase 2: commit the staged candidates atomically. Reached only when the parse + // above completed without throwing, so live state is never partially updated. + foreach (var kv in staged) _sessions[kv.Key] = kv.Value; + _sessionThinkingDefaults = stagedDefaults; + + var authoritativeCount = TryReadAuthoritativeSessionCount(envelope); + // Page-vs-total authority: count is the returned window size, not the + // inventory total. Only a verified complete inventory window may delete + // absent held keys; a partial page merges rows and preserves the rest. + var refresh = SessionCatalogAuthority.Read(envelope, incomingKeys.Count); + if (SessionCatalogRetention.ShouldPreserveOnEmpty(incomingKeys.Count, _sessions.Count, authoritativeCount) + || !refresh.AllowsDeletionOfAbsentKeys + || !allowDeletion) { + _catalogRefreshState.RecordIgnoredEmpty(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _logger.Warn(refresh.Kind == SessionCatalogRefreshKind.PartialPage + ? $"[SESSIONS] partial sessions.list window (rows={refresh.ObservedRows}, total={(refresh.TotalCount?.ToString() ?? "?")}, hasMore={refresh.HasMore}); merging and preserving {_sessions.Count} held session(s)" + : $"[SESSIONS] non-authoritative sessions.list ignored; preserving {_sessions.Count} known session(s)"); + } + else + { + _catalogRefreshState.RecordSuccessfulRefresh(); + // Remove sessions no longer present in the gateway response var staleKeys = new List(); foreach (var key in _sessions.Keys) { - if (!incomingKeys.Contains(key)) - staleKeys.Add(key); + if (incomingKeys.Contains(key)) continue; + // A scoped (agent-filtered) response may only delete that agent's own + // held sessions; other agents' sessions are always preserved. + if (requestedAgentScope is not null && !KeyBelongsToAgent(key, requestedAgentScope)) continue; + staleKeys.Add(key); } foreach (var key in staleKeys) _sessions.Remove(key); @@ -4521,32 +5168,38 @@ private void ParseSessions(JsonElement sessions) snapshot = GetSessionListInternal(); } - SessionsUpdated?.Invoke(this, snapshot); + if (raiseEvents && snapshot is not null) SessionsUpdated?.Invoke(this, snapshot); } catch (Exception ex) { + // The parse aborted before the phase-2 commit, so no live session/defaults were mutated. + // Signal a retry instead of silently accepting a partial page. _logger.Warn($"Failed to parse sessions: {ex.Message}"); + _catalogRefreshState.RecordIgnoredEmpty(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); } + return new SessionParseResult(incomingKeys, snapshot); } - private string? ParseSessionItem(JsonElement item) + /// True when a session key belongs to the given agent (keys are agent:<id>:...). + private static bool KeyBelongsToAgent(string key, string agentId) + { + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(agentId)) return false; + var parts = key.Split(':'); + return parts.Length >= 2 + && string.Equals(parts[0], "agent", StringComparison.Ordinal) + && string.Equals(parts[1], agentId, StringComparison.Ordinal); + } + + private string? ParseSessionItem(JsonElement item, Func stage, ThinkingContext? defaults) { var sessionKey = "unknown"; if (item.TryGetProperty("key", out var key)) sessionKey = key.GetString() ?? "unknown"; - // Update or create - if (!_sessions.TryGetValue(sessionKey, out var session)) - { - session = new SessionInfo { Key = sessionKey }; - } - + var session = stage(sessionKey); UpdateSessionMainStatus(session, sessionKey, item); - PopulateSessionFromObject(session, item, authoritativeSessionList: true); - session.ThinkingDefaults = _sessionThinkingDefaults; - - _sessions[session.Key] = session; + session.ThinkingDefaults = defaults; return session.Key; } diff --git a/src/OpenClaw.Shared/PendingRequestRegistry.cs b/src/OpenClaw.Shared/PendingRequestRegistry.cs index 560f0fc275..cd829e7588 100644 --- a/src/OpenClaw.Shared/PendingRequestRegistry.cs +++ b/src/OpenClaw.Shared/PendingRequestRegistry.cs @@ -16,18 +16,26 @@ internal readonly record struct PendingRequestRegistration( internal abstract class PendingRequestResolution { - protected PendingRequestResolution(string method, PendingRequestCategory category) + protected PendingRequestResolution(string method, PendingRequestCategory category, string? scope = null, object? context = null) { Method = method; Category = category; + Scope = scope; + Context = context; } internal string Method { get; } internal PendingRequestCategory Category { get; } + + /// Requested agent scope carried from the originating request, if any. + internal string? Scope { get; } + + /// Opaque per-request context (e.g. acquisition generation/offset) carried to the response. + internal object? Context { get; } } -internal sealed class TrackedRequestResolution(string method) - : PendingRequestResolution(method, PendingRequestCategory.Tracked); +internal sealed class TrackedRequestResolution(string method, string? scope = null, object? context = null) + : PendingRequestResolution(method, PendingRequestCategory.Tracked, scope, context); internal sealed class ChatSendRequestResolution( string method, @@ -88,8 +96,8 @@ internal void OpenConnection() FaultDrainedEntries(staleEntries); } - internal RegistrationHandle RegisterTracked(string requestId, string method) => - Register(new TrackedEntry(requestId, method)); + internal RegistrationHandle RegisterTracked(string requestId, string method, string? scope = null, object? context = null) => + Register(new TrackedEntry(requestId, method, scope, context)); internal PendingRequestRegistration RegisterChatSend( string requestId, @@ -240,11 +248,14 @@ internal abstract void FaultFromDrain( GatewayConnectionLostException? wizardDisconnect); } - private sealed class TrackedEntry(string requestId, string method) + private sealed class TrackedEntry(string requestId, string method, string? scope, object? context) : Entry(requestId, method, PendingRequestCategory.Tracked) { + private string? Scope { get; } = scope; + private object? Context { get; } = context; + internal override PendingRequestResolution CreateResolution() => - new TrackedRequestResolution(Method); + new TrackedRequestResolution(Method, Scope, Context); internal override void FaultFromDrain( GatewayConnectionLostException? wizardDisconnect) diff --git a/src/OpenClaw.Shared/ResumableJobCheckpoint.cs b/src/OpenClaw.Shared/ResumableJobCheckpoint.cs new file mode 100644 index 0000000000..a5583d7457 --- /dev/null +++ b/src/OpenClaw.Shared/ResumableJobCheckpoint.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; + +namespace OpenClaw.Shared; + +/// +/// U3 companion reliability: MONOTONIC, in-memory checkpoint for a long helper job. +/// Steps complete in increasing ordinal order; a resume starts at the next ordinal +/// and a repeated ordinal/step id is rejected. +/// +/// This type alone is NOT durable and does NOT establish exactly-once: no persistence or load lives here. +/// Durability comes from ; the at-least-once window between +/// applying an effect and recording it is closed by the runner's IDEMPOTENT effects plus reconciliation +/// (adopt an already-applied ordinal), never by the checkpoint alone. +/// +/// +internal sealed class ResumableJobCheckpoint +{ + /// Bound on a resumable ordinal: a checkpoint is a small cursor, not an unbounded counter. +internal const int MaxOrdinal = 1_000_000; + internal const int MaxStepIdChars = 4096; + internal const int MaxSourceIdentityChars = 4096; + + private readonly object _gate = new(); + private readonly List _completedStepIds = new(); + // O(1) duplicate/replay membership so resume does not reload with a quadratic Contains scan. + private readonly HashSet _completedStepIdSet = new(StringComparer.Ordinal); + private int _lastCompletedOrdinal; + + public ResumableJobCheckpoint(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) throw new ArgumentException("jobId required", nameof(jobId)); + JobId = jobId; + } + + public string JobId { get; } + + public int LastCompletedOrdinal + { + get { lock (_gate) { return _lastCompletedOrdinal; } } + } + + public IReadOnlyList CompletedStepIds + { + get { lock (_gate) { return _completedStepIds.ToArray(); } } + } + + /// Next ordinal to run (1-based). + public int NextOrdinal + { + get { lock (_gate) { return _lastCompletedOrdinal + 1; } } + } + + /// + /// Marks a step complete. Rejects a non-monotonic ordinal or a repeated step id so a + /// resumed job cannot replay or reorder work. + /// + public bool TryMarkCompleted(string stepId, int ordinal) + { + if (string.IsNullOrWhiteSpace(stepId)) return false; + if (stepId.Length > MaxStepIdChars) return false; + lock (_gate) + { + if (ordinal != _lastCompletedOrdinal + 1) return false; + if (ordinal > MaxOrdinal) return false; + if (!_completedStepIdSet.Add(stepId)) return false; // O(1) replay guard + _completedStepIds.Add(stepId); + _lastCompletedOrdinal = ordinal; + return true; + } + } + + /// Validates a persisted ordinal obtained from a checkpoint store. + public static bool IsResumable(int persistedLastCompletedOrdinal, int totalSteps) + => persistedLastCompletedOrdinal >= 0 && totalSteps > 0 && persistedLastCompletedOrdinal < totalSteps; + + /// + /// REBUILDS a checkpoint from a durable record (resume). The step list must be monotonic and must match + /// the recorded ordinal exactly, so a truncated or hand-edited record cannot resume silently. + /// + public static ResumableJobCheckpoint Load( + string jobId, int lastCompletedOrdinal, IReadOnlyList? completedStepIds) + { + if (lastCompletedOrdinal < 0) throw new ArgumentOutOfRangeException(nameof(lastCompletedOrdinal)); + if (lastCompletedOrdinal > MaxOrdinal) throw new ArgumentOutOfRangeException(nameof(lastCompletedOrdinal)); + var steps = completedStepIds ?? Array.Empty(); + if (steps.Count != lastCompletedOrdinal) + throw new ArgumentException("checkpoint step list does not match the recorded ordinal", nameof(completedStepIds)); + var checkpoint = new ResumableJobCheckpoint(jobId); + for (var i = 0; i < steps.Count; i++) + { + if (steps[i] is null || steps[i].Length > MaxStepIdChars) + throw new ArgumentException("checkpoint step id is null or over-long", nameof(completedStepIds)); + if (!checkpoint.TryMarkCompleted(steps[i], i + 1)) + throw new ArgumentException("checkpoint step list is not monotonic", nameof(completedStepIds)); + } + return checkpoint; + } + + /// Snapshot for durable persistence (no session history, no credentials). + public ResumableJobCheckpointRecord Record(string sourceIdentity) + { + if (sourceIdentity is null) throw new ArgumentNullException(nameof(sourceIdentity)); + if (sourceIdentity.Length > MaxSourceIdentityChars) throw new ArgumentException("source identity is over-long", nameof(sourceIdentity)); + lock (_gate) + { + return new ResumableJobCheckpointRecord( + JobId, sourceIdentity, _lastCompletedOrdinal, _completedStepIds.ToArray()); + } + } +} diff --git a/src/OpenClaw.Shared/ResumableJobCheckpointStore.cs b/src/OpenClaw.Shared/ResumableJobCheckpointStore.cs new file mode 100644 index 0000000000..efcc3314fd --- /dev/null +++ b/src/OpenClaw.Shared/ResumableJobCheckpointStore.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// Outcome of binding a helper job to its checkpoint record. +internal enum ResumableJobCheckpointOutcome +{ + /// No record existed; a fresh checkpoint was created. + Started, + /// An existing record for the SAME job and source identity was loaded (resume). + Resumed, + /// A record for the job exists but belongs to a DIFFERENT source identity (preserved, not clobbered). + IdentityCollision, + /// The record is missing/mis-scoped/oversized/invalid and was PRESERVED (never deleted). + Unreadable, +} + +/// +/// Serializable, self-describing checkpoint record. Deliberately contains NO session history and NO +/// credentials: only the job id, the owning source identity, and the completed ordinals/step ids. +/// +internal sealed record ResumableJobCheckpointRecord( + string JobId, + string SourceIdentity, + int LastCompletedOrdinal, + IReadOnlyList CompletedStepIds); + +/// +/// Owned store for a long helper job's checkpoint record, scoped by (jobId, sourceIdentity) and persisted +/// through . +/// +/// Record files are named by a stable SHA-256 hex of the job id, never a lossy sanitization; the STORED job +/// identity is verified on load, so the (negligible, not impossible) digest collision cannot silently resume +/// the wrong job. +/// +/// +/// The record is a tiny cursor, not a place for history, so reads are BOUNDED: a record whose size cannot be +/// established, or which exceeds the byte/char bound, is Unreadable and PRESERVED. Saves take the in-process +/// gate and do a check-and-set: the current receipt is READ, VALIDATED, and its source identity, ordinal, and +/// completed prefix verified BEFORE any replacement. The serialized output is bound-checked BEFORE the write, +/// and a unique temp is READ BACK and compared for EXACT equality before promotion; any failure PRESERVES the +/// previous record and the failed artifact. +/// +/// +/// HONEST LIMITS: this is not a power-loss (no fsync) or cross-process-exclusive guarantee. It does NOT make +/// the job exactly-once: the at-least-once window between applying an effect and recording it is closed by the +/// runner's IDEMPOTENT effects plus reconciliation, never by the checkpoint. SHA-256 filename collisions are +/// negligible, not impossible. +/// +/// +internal sealed class ResumableJobCheckpointStore +{ + // Bounded record limits: a checkpoint is a tiny cursor. A record beyond these bounds is Unreadable and + // PRESERVED, never parsed (and never written in the first place). + internal const int MaxRecordBytes = 1024 * 1024; + internal const int MaxRecordChars = 256 * 1024; + + // In-process serialization of saves. Bounded: ONE gate, no per-job map. Cross-process exclusivity is NOT + // claimed here. + private static readonly object SaveGate = new(); + + private readonly IFileSystem _fileSystem; + private readonly string _rootDirectory; + + public ResumableJobCheckpointStore(string rootDirectory, IFileSystem? fileSystem = null) + { + if (string.IsNullOrWhiteSpace(rootDirectory)) + throw new ArgumentException("rootDirectory required", nameof(rootDirectory)); + _rootDirectory = rootDirectory; + _fileSystem = fileSystem ?? RealFileSystem.Instance; + } + + /// Stable hashed record path (collision-free in practice; stored identity still verified on load). + private string RecordPath(string jobId) => + Path.Combine(_rootDirectory, + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(jobId))).ToLowerInvariant() + + ".checkpoint.json"); + + /// + /// Binds a job to its checkpoint BEFORE any work starts. Returns the checkpoint to resume from, or an explicit + /// non-resumable outcome (identity collision / unreadable) that PRESERVES the existing record. + /// + public ResumableJobCheckpointOutcome TryBind( + string jobId, + string sourceIdentity, + out ResumableJobCheckpoint? checkpoint, + out string? failure) + { + if (string.IsNullOrWhiteSpace(jobId)) throw new ArgumentException("jobId required", nameof(jobId)); + if (sourceIdentity is null) throw new ArgumentNullException(nameof(sourceIdentity)); + checkpoint = null; + failure = null; + + var path = RecordPath(jobId); + if (!_fileSystem.FileExists(path)) + { + checkpoint = new ResumableJobCheckpoint(jobId); + return ResumableJobCheckpointOutcome.Started; + } + + if (!TryReadRecord(path, jobId, out var record, out failure)) + return ResumableJobCheckpointOutcome.Unreadable; // preserved, never deleted + + if (!string.Equals(record!.SourceIdentity, sourceIdentity, StringComparison.Ordinal)) + { + failure = $"checkpoint record for '{jobId}' belongs to a different source identity; preserved."; + return ResumableJobCheckpointOutcome.IdentityCollision; // preserved, never clobbered + } + + if (!TryLoadRecord(record, out checkpoint, out failure)) + return ResumableJobCheckpointOutcome.Unreadable; // preserved + + return ResumableJobCheckpointOutcome.Resumed; + } + + private bool TryReadRecord(string path, string jobId, out ResumableJobCheckpointRecord? record, out string? failure) + { + record = null; + failure = null; + try + { + // BOUND BEFORE ALLOCATION: a record whose size cannot be established is NOT read at all. + var length = _fileSystem.GetFileLength(path); + if (length < 0) + { + failure = $"checkpoint record for '{jobId}' has no verifiable size; preserved."; + return false; + } + if (length > MaxRecordBytes) + { + failure = $"checkpoint record for '{jobId}' exceeds {MaxRecordBytes} bytes; preserved."; + return false; + } + + var text = _fileSystem.ReadAllText(path); + if (text.Length > MaxRecordChars) + { + failure = $"checkpoint record for '{jobId}' exceeds {MaxRecordChars} chars; preserved."; + return false; + } + + var parsed = JsonSerializer.Deserialize(text); + if (parsed is null || parsed.CompletedStepIds is null + || !string.Equals(parsed.JobId, jobId, StringComparison.Ordinal)) + { + failure = $"checkpoint record for '{jobId}' was empty or mis-scoped."; + return false; + } + + record = parsed; + return true; + } + catch (Exception exception) + { + failure = $"checkpoint record for '{jobId}' could not be read: {exception.Message}"; + return false; + } + } + + private static bool TryLoadRecord( + ResumableJobCheckpointRecord record, + out ResumableJobCheckpoint? checkpoint, + out string? failure) + { + checkpoint = null; + failure = null; + try + { + checkpoint = ResumableJobCheckpoint.Load(record.JobId, record.LastCompletedOrdinal, record.CompletedStepIds); + return true; + } + catch (Exception exception) + { + failure = $"checkpoint record for '{record.JobId}' has an invalid ordinal/step list: {exception.Message}"; + return false; + } + } + + /// + /// Records the checkpoint with check-and-set: the current receipt is read, validated, and its source + /// identity/ordinal/prefix verified BEFORE replacement; the serialized output is bound-checked BEFORE the + /// write; a unique temp is read back and compared for EXACT equality before promotion. + /// + public bool TrySave(ResumableJobCheckpoint checkpoint, string sourceIdentity, out string? failure) + { + if (checkpoint is null) throw new ArgumentNullException(nameof(checkpoint)); + if (sourceIdentity is null) throw new ArgumentNullException(nameof(sourceIdentity)); + failure = null; + + ResumableJobCheckpointRecord next; + try + { + next = checkpoint.Record(sourceIdentity); + } + catch (Exception exception) + { + failure = $"checkpoint for '{checkpoint.JobId}' is not saveable: {exception.Message}"; + return false; + } + + if (next.LastCompletedOrdinal < 0 || next.CompletedStepIds.Count != next.LastCompletedOrdinal) + { + failure = $"checkpoint for '{checkpoint.JobId}' is inconsistent; refused."; + return false; + } + + var path = RecordPath(checkpoint.JobId); + lock (SaveGate) + { + if (_fileSystem.FileExists(path)) + { + if (!TryReadRecord(path, checkpoint.JobId, out var existing, out var readFailure)) + { + failure = readFailure; + return false; // preserved + } + + // The EXISTING record must itself be valid before it is replaced. + if (!TryLoadRecord(existing!, out _, out var existingFailure)) + { + failure = existingFailure; + return false; // preserved, never silently overwritten + } + + if (!string.Equals(existing!.SourceIdentity, sourceIdentity, StringComparison.Ordinal)) + { + failure = $"checkpoint record for '{checkpoint.JobId}' belongs to a different source identity; refused."; + return false; + } + + if (existing.LastCompletedOrdinal > next.LastCompletedOrdinal) + { + failure = $"checkpoint for '{checkpoint.JobId}' would regress ordinal " + + $"{existing.LastCompletedOrdinal} to {next.LastCompletedOrdinal}; refused."; + return false; + } + + if (!IsPrefix(existing.CompletedStepIds, next.CompletedStepIds)) + { + failure = $"checkpoint for '{checkpoint.JobId}' would drop previously completed steps; refused."; + return false; + } + } + + // SERIALIZED-OUTPUT BOUND: a save this store could not read back is not a valid save. Checked BEFORE + // the write, so the previous record stands. + var json = JsonSerializer.Serialize(next); + if (json.Length > MaxRecordChars || Encoding.UTF8.GetByteCount(json) > MaxRecordBytes) + { + failure = $"checkpoint for '{checkpoint.JobId}' serializes beyond the readable bound " + + $"({MaxRecordChars} chars / {MaxRecordBytes} bytes); refused."; + return false; // previous record preserved + } + + var unique = Guid.NewGuid().ToString("N"); + var temp = path + "." + unique + ".tmp"; + try + { + _fileSystem.CreateDirectory(_rootDirectory); + _fileSystem.WriteAllText(temp, json); + + // READBACK: compare the COMPLETE exact serialized text (job id, source, ordinal, AND the full step + // list). A partially written or identity-corrupted temp is never promoted. + var readbackText = _fileSystem.ReadAllText(temp); + if (!string.Equals(readbackText, json, StringComparison.Ordinal)) + throw new InvalidDataException("temp checkpoint readback did not match the exact written text"); + + _fileSystem.MoveFile(temp, path, overwrite: true); + return true; + } + catch (Exception exception) + { + failure = $"checkpoint record for '{checkpoint.JobId}' could not be saved: {exception.Message}"; + PreserveFailedArtifact(temp, unique); // PRESERVE the failed artifact; never delete it + return false; + } + } + } + + private void PreserveFailedArtifact(string temp, string unique) + { + try + { + if (!_fileSystem.FileExists(temp)) return; + var failed = temp.Substring(0, temp.Length - ".tmp".Length) + ".failed." + unique + ".json"; + _fileSystem.MoveFile(temp, failed, overwrite: false); + } + catch + { + // Best effort: if the artifact cannot be renamed it is left in place, still preserved. + } + } + + private static bool IsPrefix(IReadOnlyList prefix, IReadOnlyList full) + { + if (prefix.Count > full.Count) return false; + for (var i = 0; i < prefix.Count; i++) + { + if (!string.Equals(prefix[i], full[i], StringComparison.Ordinal)) return false; + } + return true; + } +} \ No newline at end of file diff --git a/src/OpenClaw.Shared/SessionCatalogAuthority.cs b/src/OpenClaw.Shared/SessionCatalogAuthority.cs new file mode 100644 index 0000000000..df9c1a449a --- /dev/null +++ b/src/OpenClaw.Shared/SessionCatalogAuthority.cs @@ -0,0 +1,234 @@ +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// U2/catalog companion reliability (page-vs-total authority + consistency gates). +/// +/// Installed Gateway sessions.list contract (session-utils-list-Cjeh9QIy.mjs): +/// count -> rows returned in THIS window (page size, not the inventory total) +/// totalCount -> FILTERED inventory total +/// hasMore -> nextOffset < filtered.length +/// nextOffset -> offset + rows, or NULL when the page set is done +/// +/// An absent-keys deletion may only happen on a verified complete inventory window. +/// Completeness is trusted only when the declared metadata AGREES with the rows actually +/// returned and admitted. A present positive nextOffset (even with hasMore false), a +/// declared count/total that disagrees, an admitted-count mismatch, or present-but-invalid +/// paging metadata all withhold deletion authority. +/// +/// Field-specific nullability: null nextOffset is valid TERMINAL metadata (page set done). +/// Null count/totalCount/offset are NOT accepted - they remain present-but-invalid. +/// +internal enum SessionCatalogRefreshKind +{ + /// No usable paging metadata: completeness is not proven. + Unknown = 0, + /// A verified complete inventory window (metadata agrees with observed rows). + CompleteInventory = 1, + /// A partial/inconsistent window: merge rows, preserve held keys. + PartialPage = 2 +} + +internal readonly struct SessionCatalogRefresh +{ + public SessionCatalogRefreshKind Kind { get; init; } + + /// Rows actually observed in the returned payload (never a declared count). + public int ObservedRows { get; init; } + + public int? DeclaredCount { get; init; } + public int? TotalCount { get; init; } + public bool HasMore { get; init; } + public bool HasMorePresent { get; init; } + public int? NextOffset { get; init; } + public int? Offset { get; init; } + public bool HasInvalidPagingMetadata { get; init; } + + /// True only when absent held keys may be removed. + public bool AllowsDeletionOfAbsentKeys => Kind == SessionCatalogRefreshKind.CompleteInventory; +} + +internal static class SessionCatalogAuthority +{ + /// + /// Keys the caller actually admitted from this payload (-1 when unknown). Used to detect + /// a window whose metadata claims more rows than we could admit. + /// + internal static SessionCatalogRefresh Read(JsonElement envelope, int admittedKeyCount = -1) + { + if (envelope.ValueKind == JsonValueKind.Array) + { + var rows = envelope.GetArrayLength(); + var arrayKind = rows == 0 + ? SessionCatalogRefreshKind.Unknown + : admittedKeyCount >= 0 && admittedKeyCount != rows + ? SessionCatalogRefreshKind.PartialPage + : SessionCatalogRefreshKind.CompleteInventory; + return new SessionCatalogRefresh { ObservedRows = rows, Kind = arrayKind }; + } + + if (envelope.ValueKind != JsonValueKind.Object) + return new SessionCatalogRefresh { Kind = SessionCatalogRefreshKind.Unknown }; + + var hasInner = envelope.TryGetProperty("sessions", out var inner) && inner.ValueKind == JsonValueKind.Object; + var innerObj = hasInner ? inner : default; + var scope = hasInner ? inner : envelope; + + var observedRows = CountRows(envelope, innerObj); + + var invalid = false; + // nextOffset may be null (valid terminal); count/totalCount/offset may NOT. + var declaredCount = ReadPagingInt(scope, envelope, "count", allowNull: false, ref invalid); + var totalCount = ReadPagingInt(scope, envelope, "totalCount", allowNull: false, ref invalid); + var nextOffset = ReadPagingInt(scope, envelope, "nextOffset", allowNull: true, ref invalid); + var offset = ReadPagingInt(scope, envelope, "offset", allowNull: false, ref invalid); + var hasMore = ReadPagingBool(scope, envelope, "hasMore", ref invalid, out var hasMorePresent); + + var kind = Classify(observedRows, admittedKeyCount, declaredCount, totalCount, hasMore, nextOffset, offset, invalid); + + return new SessionCatalogRefresh + { + Kind = kind, + ObservedRows = observedRows, + DeclaredCount = declaredCount, + TotalCount = totalCount, + HasMore = hasMore, + HasMorePresent = hasMorePresent, + NextOffset = nextOffset, + Offset = offset, + HasInvalidPagingMetadata = invalid + }; + } + + private static SessionCatalogRefreshKind Classify( + int observedRows, int admittedKeyCount, int? declaredCount, int? totalCount, + bool hasMore, int? nextOffset, int? offset, bool invalid) + { + // Present-but-invalid paging metadata must not silently become "absent" and grant deletion. + if (invalid) return SessionCatalogRefreshKind.PartialPage; + if (hasMore) return SessionCatalogRefreshKind.PartialPage; + if (offset is > 0) return SessionCatalogRefreshKind.PartialPage; + // A present positive nextOffset implies a pending page, even if hasMore says false. + if (nextOffset is > 0) return SessionCatalogRefreshKind.PartialPage; + // A declared count must agree with the rows actually returned. + if (declaredCount.HasValue && declaredCount.Value != observedRows) return SessionCatalogRefreshKind.PartialPage; + // The (filtered) inventory total must agree with the returned rows for a complete window. + if (totalCount.HasValue && totalCount.Value != observedRows) return SessionCatalogRefreshKind.PartialPage; + // When completeness is declared, the rows must also be admissible. + if ((declaredCount.HasValue || totalCount.HasValue) && admittedKeyCount >= 0 && admittedKeyCount != observedRows) + return SessionCatalogRefreshKind.PartialPage; + if (totalCount.HasValue) return SessionCatalogRefreshKind.CompleteInventory; + return declaredCount.HasValue || observedRows > 0 + ? SessionCatalogRefreshKind.CompleteInventory + : SessionCatalogRefreshKind.Unknown; + } + + private static int CountRows(JsonElement envelope, JsonElement inner) + { + if (inner.ValueKind == JsonValueKind.Array) return inner.GetArrayLength(); + if (inner.ValueKind == JsonValueKind.Object) return CountObjectRows(inner); + if (envelope.TryGetProperty("sessions", out var rows)) + { + if (rows.ValueKind == JsonValueKind.Array) return rows.GetArrayLength(); + if (rows.ValueKind == JsonValueKind.Object) return CountObjectRows(rows); + } + return 0; + } + + private static int CountObjectRows(JsonElement obj) + { + var count = 0; + foreach (var prop in obj.EnumerateObject()) + { + if (IsMetaKey(prop.Name)) continue; + count++; + } + return count; + } + + internal static bool IsMetaKey(string name) + => name is "recent" or "count" or "totalCount" or "hasMore" or "nextOffset" or "offset" or "path" or "defaults" or "ts"; + + /// Reads a paging integer. Null is valid only where is set. + private static int? ReadPagingInt(JsonElement primary, JsonElement secondary, string name, bool allowNull, ref bool invalid) + { + if (TryGetProperty(primary, name, out var prop) || TryGetProperty(secondary, name, out prop)) + { + if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var value) && value >= 0) + return value; + if (allowNull && prop.ValueKind == JsonValueKind.Null) + return null; + invalid = true; + return null; + } + return null; + } + + /// Reads a paging bool, marking present-but-invalid (non-bool, including null) values. + private static bool ReadPagingBool(JsonElement primary, JsonElement secondary, string name, ref bool invalid, out bool present) + { + present = false; + if (TryGetProperty(primary, name, out var prop) || TryGetProperty(secondary, name, out prop)) + { + present = true; + if (prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False) + return prop.GetBoolean(); + invalid = true; + return false; + } + return false; + } + + private static bool TryGetProperty(JsonElement obj, string name, out JsonElement value) + { + value = default; + return obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out value); + } + + /// + /// Non-mutating scan of a sessions.list payload that mirrors the client's row-admission + /// rules. Used to validate a page (rows/keys/metadata) BEFORE any catalog mutation. + /// + internal static (int ObservedRows, List AdmittedKeys) ScanPage(JsonElement envelope) + { + var keys = new List(); + var rows = envelope; + if (envelope.ValueKind == JsonValueKind.Object && envelope.TryGetProperty("sessions", out var inner)) + rows = inner; + + if (rows.ValueKind == JsonValueKind.Array) + { + foreach (var item in rows.EnumerateArray()) + { + var key = item.ValueKind == JsonValueKind.Object && item.TryGetProperty("key", out var k) && k.ValueKind == JsonValueKind.String + ? (k.GetString() ?? "unknown") + : "unknown"; + keys.Add(key); + } + return (keys.Count, keys); + } + + if (rows.ValueKind == JsonValueKind.Object) + { + foreach (var prop in rows.EnumerateObject()) + { + var name = prop.Name; + if (IsMetaKey(name)) continue; + if (!name.Equals("global", StringComparison.OrdinalIgnoreCase) && + !name.Contains(':') && !name.Contains("agent") && !name.Contains("session")) continue; + var item = prop.Value; + if (item.ValueKind == JsonValueKind.String) + { + var s = item.GetString() ?? ""; + if (s.StartsWith("/", StringComparison.Ordinal) || s.Contains("/.", StringComparison.Ordinal)) continue; + } + else if (item.ValueKind == JsonValueKind.Number) continue; + keys.Add(name); + } + return (keys.Count, keys); + } + + return (0, keys); + } +} diff --git a/src/OpenClaw.Shared/SessionCatalogPageSet.cs b/src/OpenClaw.Shared/SessionCatalogPageSet.cs new file mode 100644 index 0000000000..7b68c6120b --- /dev/null +++ b/src/OpenClaw.Shared/SessionCatalogPageSet.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; + +namespace OpenClaw.Shared; + +internal enum PageSetDecision +{ + Continue, + Complete, + Failed +} + +/// +/// Bounded complete page-set acquisition for sessions.list. +/// +/// Installed Gateway contract (session-utils-list-Cjeh9QIy.mjs): request opts.limit/opts.offset; +/// response count (window rows), totalCount (filtered inventory), hasMore, nextOffset +/// (offset + rows, or null when done). The Gateway emits NO server inventory revision / etag, +/// so a multi-page union cannot be proven to be a stable snapshot. +/// +/// Consequence: only a SINGLE-page complete inventory may delete absent held keys. A multi-page +/// set merges rows and retains the held catalog (see AllowsDeletion). Bounds (pages/rows/bytes/ +/// deadline) and continuation checks (advancing offsets, stable totals, no duplicates, valid +/// terminal) reject a bad acquisition so the caller preserves the prior catalog. +/// +internal sealed class SessionCatalogPageSet +{ + public const int DefaultPageSize = 200; + public const int DefaultMaxPages = 20; + public const int DefaultMaxRows = 2000; + public const long DefaultMaxBytes = 2_000_000; + public static readonly TimeSpan DefaultDeadline = TimeSpan.FromSeconds(8); + + private readonly HashSet _keys = new(StringComparer.Ordinal); + private readonly HashSet _offsetsSeen = new(); + private readonly long _startedMs; + private readonly long _deadlineMs; + private readonly long _maxBytes; + private readonly int _maxPages; + private readonly int _maxRows; + + public SessionCatalogPageSet( + string? scope, + long generation, + long nowMs, + int maxPages = DefaultMaxPages, + int maxRows = DefaultMaxRows, + long maxBytes = DefaultMaxBytes, + TimeSpan? deadline = null) + { + Scope = scope; + Generation = generation; + _startedMs = nowMs; + _maxPages = maxPages; + _maxRows = maxRows; + _maxBytes = maxBytes; + _deadlineMs = (long)(deadline ?? DefaultDeadline).TotalMilliseconds; + } + + public string? Scope { get; } + public long Generation { get; } + public int Pages { get; private set; } + public int Rows { get; private set; } + public long Bytes { get; private set; } + public int? ExpectedTotalCount { get; private set; } + + /// Offset of the page the caller should request next, or null when done. + public int? NextOffset { get; private set; } + + public PageSetDecision Decision { get; private set; } = PageSetDecision.Continue; + public string? Failure { get; private set; } + + /// Only a proven single-page complete inventory may remove absent held keys. + public bool AllowsDeletion => Decision == PageSetDecision.Complete && Pages == 1; + + /// Feeds one received page and returns the next decision. + public PageSetDecision Accept( + SessionCatalogRefresh refresh, + IReadOnlyCollection admittedKeys, + int requestedOffset, + long rawBytes, + long nowMs) + { + if (Decision != PageSetDecision.Continue) return Decision; + if (nowMs - _startedMs > _deadlineMs) return Fail("acquisition deadline exceeded"); + if (Pages >= _maxPages) return Fail("max pages exceeded"); + if (Rows + admittedKeys.Count > _maxRows) return Fail("max rows exceeded"); + if (Bytes + rawBytes > _maxBytes) return Fail("max bytes exceeded"); + if (refresh.Kind == SessionCatalogRefreshKind.Unknown) return Fail("unqualified page"); + if (refresh.HasInvalidPagingMetadata) return Fail("invalid paging metadata"); + + if (refresh.Offset is int responseOffset && responseOffset != requestedOffset) return Fail("unexpected page offset"); + if (!_offsetsSeen.Add(requestedOffset)) return Fail("repeated page offset"); + + foreach (var key in admittedKeys) + { + if (!_keys.Add(key)) return Fail("duplicate key across pages"); + } + + // A page that declares itself complete must agree with the rows we admitted here. + if (refresh.Kind == SessionCatalogRefreshKind.CompleteInventory && refresh.ObservedRows != admittedKeys.Count) + return Fail("declared/admitted row count mismatch"); + + if (refresh.TotalCount is int total) + { + if (ExpectedTotalCount is null) ExpectedTotalCount = total; + else if (ExpectedTotalCount.Value != total) return Fail("changing totalCount"); + } + + Pages++; + Rows += admittedKeys.Count; + Bytes += rawBytes; + + if (!refresh.HasMore && refresh.NextOffset is null) + { + // A terminal page must not claim completeness with a contradictory cumulative count. + if (ExpectedTotalCount is int expectedTotal && Rows != expectedTotal) + return Fail("terminal cumulative rows do not match totalCount"); + Decision = PageSetDecision.Complete; + NextOffset = null; + return Decision; + } + + if (refresh.NextOffset is int next && next > requestedOffset && !_offsetsSeen.Contains(next)) + { + NextOffset = next; + return PageSetDecision.Continue; + } + + return Fail("malformed or nonadvancing continuation"); + } + + private PageSetDecision Fail(string reason) + { + Decision = PageSetDecision.Failed; + Failure = reason; + NextOffset = null; + return Decision; + } +} diff --git a/src/OpenClaw.Shared/SessionCatalogRefreshState.cs b/src/OpenClaw.Shared/SessionCatalogRefreshState.cs new file mode 100644 index 0000000000..d1ecb610b6 --- /dev/null +++ b/src/OpenClaw.Shared/SessionCatalogRefreshState.cs @@ -0,0 +1,46 @@ +namespace OpenClaw.Shared; + +/// +/// U2 companion reliability: tracks whether a session-catalog refresh was ignored +/// because it returned no rows while a valid catalog is still held. The UI layer can +/// surface a retry/error state from this without ever clearing the held catalog. +/// +internal sealed class SessionCatalogRefreshState +{ + private readonly object _gate = new(); + private int _ignoredEmptyRefreshes; + private long _lastIgnoredUnixMs; + + /// True while at least one empty refresh has been ignored and no successful refresh has followed. + public bool RetryRequired + { + get { lock (_gate) { return _ignoredEmptyRefreshes > 0; } } + } + + public int IgnoredEmptyRefreshes + { + get { lock (_gate) { return _ignoredEmptyRefreshes; } } + } + + public long LastIgnoredUnixMs + { + get { lock (_gate) { return _lastIgnoredUnixMs; } } + } + + public void RecordIgnoredEmpty(long nowUnixMs) + { + lock (_gate) + { + _ignoredEmptyRefreshes++; + _lastIgnoredUnixMs = nowUnixMs; + } + } + + public void RecordSuccessfulRefresh() + { + lock (_gate) + { + _ignoredEmptyRefreshes = 0; + } + } +} diff --git a/src/OpenClaw.Shared/SessionCatalogRefreshStateChangedEventArgs.cs b/src/OpenClaw.Shared/SessionCatalogRefreshStateChangedEventArgs.cs new file mode 100644 index 0000000000..d9830716aa --- /dev/null +++ b/src/OpenClaw.Shared/SessionCatalogRefreshStateChangedEventArgs.cs @@ -0,0 +1,17 @@ +using System; + +namespace OpenClaw.Shared; + +/// +/// Raised after a session-catalog refresh transition is accepted (retry needed, or cleared), +/// outside any acquisition lock. Lets UI surface a retry/error state even when the session +/// collection itself did not change (timeout, rejected page, failed send, parse failure). +/// +public sealed class SessionCatalogRefreshStateChangedEventArgs : EventArgs +{ + public bool RetryRequired { get; init; } + + /// True while a bounded acquisition is in progress (begin..terminal). UI disables + /// Retry until this returns to false at an actual terminal transition, not at socket-send completion. + public bool AcquisitionInProgress { get; init; } +} diff --git a/src/OpenClaw.Shared/SessionCatalogRetention.cs b/src/OpenClaw.Shared/SessionCatalogRetention.cs new file mode 100644 index 0000000000..122de3b7d0 --- /dev/null +++ b/src/OpenClaw.Shared/SessionCatalogRetention.cs @@ -0,0 +1,12 @@ +namespace OpenClaw.Shared; + +/// +/// P2 (companion reliability): retention policy for the session catalog when a +/// refresh returns no rows. A transient or failed sessions.list must not delete a +/// catalog we already hold; an explicit authoritative count still wins. +/// +internal static class SessionCatalogRetention +{ + internal static bool ShouldPreserveOnEmpty(int incomingKeyCount, int heldCount, int? authoritativeCount) + => incomingKeyCount == 0 && heldCount > 0 && authoritativeCount != 0; +} diff --git a/src/OpenClaw.Shared/SessionCatalogRetryActionPolicy.cs b/src/OpenClaw.Shared/SessionCatalogRetryActionPolicy.cs new file mode 100644 index 0000000000..2b583c6bde --- /dev/null +++ b/src/OpenClaw.Shared/SessionCatalogRetryActionPolicy.cs @@ -0,0 +1,22 @@ +namespace OpenClaw.Shared; + +/// +/// Narrow, pure action policy for the tray session-catalog retry surface. The UI handler consumes +/// this so the retry-coalescing rule is unit-testable off the UI thread. It deliberately does NOT +/// coalesce explicit OpenClawGatewayClient.RequestSessionsAsync calls (those supersede by design); +/// it only decides whether the visible Retry action may run. +/// +public static class SessionCatalogRetryActionPolicy +{ + /// Retry is enabled only while a retry is actually needed and no acquisition/send is in flight. + public static bool IsRetryEnabled(bool connected, bool retryRequired, bool acquisitionInProgress, bool sendInFlight) + => connected && retryRequired && !acquisitionInProgress && !sendInFlight; + + /// Same rule, used to guard a click so repeat presses cannot supersede the current deadline. + public static bool CanStartRetry(bool connected, bool retryRequired, bool acquisitionInProgress, bool sendInFlight) + => IsRetryEnabled(connected, retryRequired, acquisitionInProgress, sendInFlight); + + /// The retry prompt is shown only while connected and a retry is required. + public static bool IsRetryPromptVisible(bool connected, bool retryRequired) + => connected && retryRequired; +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs b/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs index 445b39d72e..6ef9f8e374 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs @@ -149,37 +149,58 @@ public Task RequestChatHistoryAsync( string? sessionKey) { HistoryRequestCount++; - return Task.FromResult(new ChatHistoryInfo - { - SessionId = SessionId, - SessionKey = ThreadId, - Messages = - [ - new ChatMessageInfo - { - Role = "assistant", - Ts = 200, - ToolContent = - [ - new ChatToolContentInfo - { - Kind = ChatToolContentKind.Call, - CallId = "history-tool-0", - ToolName = "Exec", - }, - ], - }, - new ChatMessageInfo - { - Role = "toolresult", - Text = - "flattened output owned by history-tool-1", - Ts = 300, - }, - ], - }); + return Task.FromResult(BuildHistory()); } + // The production loader consumes the bounded typed page; this fixture supplies one exhausted + // synthetic page with the same messages (no legacy unbounded fallback, no production bypass). + public Task RequestChatHistoryPageAsync( + string? sessionKey, + ChatHistoryPageOptions options, + CancellationToken cancellationToken = default) + { + HistoryRequestCount++; + var info = BuildHistory(); + return Task.FromResult(new GatewayChatHistoryPage( + info.SessionKey, + info.SessionId, + info.Messages, + HasMore: false, + NextOffset: null, + ResponseOffset: options.Offset, + Total: info.Messages.Count)); + } + + private ChatHistoryInfo BuildHistory() => new() + { + SessionId = SessionId, + SessionKey = ThreadId, + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Ts = 200, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "history-tool-0", + ToolName = "Exec", + }, + ], + }, + new ChatMessageInfo + { + Role = "toolresult", + Text = + "flattened output owned by history-tool-1", + Ts = 300, + }, + ], + }; + public Task SendChatMessageAsync( string message, string? sessionKey, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs index 494d8c86ce..716f7abdba 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs @@ -19,7 +19,55 @@ internal sealed class ChatConversationState private readonly ChatLifecycleState _lifecycle = new(); private readonly ChatResetState _reset = new(); private readonly Dictionary _timelines = new(); - private readonly Dictionary> _entryMeta = new(); + + // Explicit USAGE/TOPOLOGY revision, mutated ONLY under _gate. Bumped when the retained usage + // topology or usage metadata changes (accepted commit, older page, session usage) - NOT on + // text-only streaming edits, so the usage projection is not invalidated per streamed token. + private readonly Dictionary _usageRevisions = new(StringComparer.Ordinal); + + // RETAINED-CONTENT version: bumped under _gate for EVERY retained timeline or metadata mutation + // (streaming text/tool/media/permission, ingest, commits, reset/replacement, session usage). It is the + // EXACT compare-and-commit guard for the older-page merge: a history lease alone cannot detect a + // concurrent content/metadata change, so a stale older merge must be rejected rather than applied. + private readonly Dictionary _retainedVersions = new(StringComparer.Ordinal); + + private void BumpRetainedVersionLocked(string threadId) => + _retainedVersions[threadId] = + (_retainedVersions.TryGetValue(threadId, out var currentVersion) ? currentVersion : 0) + 1; + + /// Exact retained-content version for a thread (under _gate). + internal long RetainedVersion(string threadId) + { + lock (_gate) + return _retainedVersions.TryGetValue(threadId, out var current) ? current : 0; + } + + /// + /// Atomically captures the accepted window lease AND the exact retained-content version, so an + /// older-page merge can prove nothing changed between capture and commit. + /// + internal bool TryCaptureOlderMergeLease( + string threadId, out ChatHistoryWindowLease lease, out long retainedVersion) + { + lock (_gate) + { + if (_history.CaptureWindow( + threadId, + GetResetVersionLocked(threadId), + _disposed) is not { } captured) + { + lease = default; + retainedVersion = 0; + return false; + } + lease = captured; + retainedVersion = _retainedVersions.TryGetValue(threadId, out var current) ? current : 0; + return true; + } + } + // COW: each thread's entry metadata is an IMMUTABLE map, so an off-lock capture by reference is safe and + // any later write produces a NEW reference (cheap exact identity compare) without cloning under the lock. + private readonly Dictionary> _entryMeta = new(); private ConnectionStatus _status; private bool _disposed; @@ -103,6 +151,305 @@ internal IReadOnlyDictionary GetEntryMetadata(string } } + /// Metadata for ONE entry id (no full-dictionary copy) - used by the usage projection. + internal ChatEntryMetadata? GetEntryMetadataById(string threadId, string entryId) + { + lock (_gate) + return _entryMeta.TryGetValue(threadId, out var metadata) && + metadata.TryGetValue(entryId, out var entry) + ? entry + : null; + } + + /// + /// Bounded metadata snapshot: copies ONLY the requested (visible) entry ids, so a windowed render + /// does not copy the entire retained metadata dictionary under the lock. + /// + internal IReadOnlyDictionary GetEntryMetadataFor( + string threadId, IReadOnlyCollection entryIds) + { + lock (_gate) + { + var visible = new Dictionary(entryIds.Count, StringComparer.Ordinal); + if (_entryMeta.TryGetValue(threadId, out var metadata)) + { + foreach (var id in entryIds) + { + if (metadata.TryGetValue(id, out var entry)) + visible[id] = entry; + } + } + return visible; + } + } + + /// Cheap metadata/history revision for projection invalidation (bumped by accepted commits). + internal long MetadataRevision(string threadId) + { + lock (_gate) // ChatHistoryState requires serialization under the root lock + return _history.SnapshotRevisions().TryGetValue(threadId, out var revision) ? revision : 0; + } + + /// + /// Atomically captures the ACCEPTED identity and usage/topology revision as a coherent pair (the + /// projection key), so a concurrent writer cannot produce a torn identity/revision combination. + /// + internal (string? AcceptedIdentity, long UsageRevision) TryCaptureUsageSnapshot(string threadId) + { + lock (_gate) + { + var identity = _history.GetAcceptedTranscriptId(threadId); + var revision = _usageRevisions.TryGetValue(threadId, out var current) ? current : 0; + return (identity, revision); + } + } + + /// + /// Per-thread BOUNDED, RESUMABLE usage scan. Everything (retained timeline, per-entry metadata and + /// the usage revision) is read under _gate, so the produced result is coherent by construction - there + /// is no capture/release window in which metadata can advance under an older revision. A revision or + /// identity change resets the scan (stale partial work is discarded). + /// + private sealed class UsageScanState + { + public long Revision; + public string? Identity; + public int NextIndex; + public string? Summary; + public string? EntryId; + public bool Complete; + public bool SummaryFromThisPass; + } + + private readonly Dictionary _usageScans = new(StringComparer.Ordinal); + + /// Entries examined per call - keeps a first initialization/miss off the unbounded-UI path. + internal const int UsageScanStepsPerCall = 64; + + /// Drops the bounded usage scan for a thread (reset / identity change / removal). + internal void ForgetUsageScan(string threadId) + { + lock (_gate) + _usageScans.Remove(threadId); + } + + /// + /// Advances the bounded usage scan by at most entries and returns the + /// current faithful result. While incomplete the PREVIOUS faithful result (if any) is returned together + /// with Complete=false, so the UI never stalls unboundedly and never shows a torn value. + /// + internal (string? Summary, string? EntryId, bool Complete) AdvanceLatestUsage( + string threadId, int maxSteps = UsageScanStepsPerCall) + { + lock (_gate) + { + if (!_timelines.TryGetValue(threadId, out var timeline)) + return (null, null, true); + var identity = _history.GetAcceptedTranscriptId(threadId); + var revision = _usageRevisions.TryGetValue(threadId, out var current) ? current : 0; + if (!_usageScans.TryGetValue(threadId, out var scan)) + { + scan = new UsageScanState + { + Revision = revision, + Identity = identity, + NextIndex = timeline.Entries.Count - 1, + }; + _usageScans[threadId] = scan; + } + else if (scan.Revision != revision || + !string.Equals(scan.Identity, identity, StringComparison.Ordinal)) + { + // New revision/identity: discard stale partial work and restart from the retained tail. + // The previous faithful result is carried ONLY while the transcript identity is unchanged, + // so a pending pass never shows a torn/older value; an identity change clears it. + var sameTranscript = string.Equals(scan.Identity, identity, StringComparison.Ordinal); + scan = new UsageScanState + { + Revision = revision, + Identity = identity, + NextIndex = timeline.Entries.Count - 1, + Summary = sameTranscript ? scan.Summary : null, + EntryId = sameTranscript ? scan.EntryId : null, + SummaryFromThisPass = false, + }; + _usageScans[threadId] = scan; + } + + var steps = 0; + while (!scan.Complete && steps < maxSteps && scan.NextIndex >= 0) + { + var entry = timeline.Entries[scan.NextIndex]; + scan.NextIndex--; + steps++; + if (entry.Kind != ChatTimelineItemKind.Assistant) + continue; + var metadata = _entryMeta.TryGetValue(threadId, out var map) && + map.TryGetValue(entry.Id, out var entryMeta) + ? entryMeta + : null; + if (metadata is null) + continue; + var text = ChatUsageFormatter.Format(metadata); + if (string.IsNullOrWhiteSpace(text)) + continue; + scan.Summary = text; + scan.EntryId = entry.Id; + scan.Complete = true; + scan.SummaryFromThisPass = true; + } + if (scan.NextIndex < 0) + scan.Complete = true; + if (scan.Complete && !scan.SummaryFromThisPass) + { + // The pass found nothing for the new revision: drop a carried (now stale) result. + scan.Summary = null; + scan.EntryId = null; + } + + return (scan.Summary, scan.EntryId, scan.Complete); + } + } + + + /// + /// Immutable roots captured UNDER the gate for an OFF-LOCK tentative older-page merge. The retained + /// ChatTimelineState reference changes on ANY timeline write (streaming/permission/local-user/status), + /// and the immutable metadata map reference changes on ANY metadata write, so comparing these two + /// references at commit is an exact, comprehensive guard (a content-revision counter alone is not). + /// + internal sealed record OlderMergeRoots( + ChatHistoryWindowLease Lease, + int ExpectedOffset, + string ThreadId, + ChatTimelineState Timeline, + System.Collections.Immutable.ImmutableDictionary Metadata, + ChatHistoryWindowState Window, + long RetainedVersion); + + /// + /// Captures the cheap immutable roots for an older-page merge AFTER the page fetch, so the tentative + /// reconstruction can run OFF LOCK against a consistent snapshot. + /// + internal bool TryPrepareOlderMergeRoots( + string threadId, int expectedOffset, ChatHistoryWindowState window, + ChatHistoryWindowLease originalLease, out OlderMergeRoots roots) + { + roots = null!; + lock (_gate) + { + if (_disposed) + return false; + // The ORIGINAL NETWORK lease (captured when the request started) is the window authority; the + // content roots are merely refreshed after the fetch. A fresh capture here would admit a stale + // older response against a NEWER window revision committed while the request was in flight. + if (!_history.IsCurrent(originalLease.Token, GetResetVersionLocked(threadId), _disposed)) + return false; + if (_history.CaptureWindow( + threadId, + GetResetVersionLocked(threadId), + _disposed) is not { } lease || + lease.Revision != originalLease.Revision || + !Equals(lease.Window, originalLease.Window)) + { + return false; + } + if (lease.Window.NextOffset != expectedOffset) + return false; + var timeline = GetOrCreateTimelineLocked(threadId); + var metadata = _entryMeta.TryGetValue(threadId, out var map) + ? map + : System.Collections.Immutable.ImmutableDictionary.Empty; + roots = new OlderMergeRoots( + lease, + expectedOffset, + threadId, + timeline, + metadata, + window, + _retainedVersions.TryGetValue(threadId, out var rv) ? rv : 0); + return true; + } + } + + /// + /// Atomic compare-and-commit for an OFF-LOCK tentative older merge. Publishes ONLY when the captured + /// timeline AND metadata references are still current (plus the lease/identity/offset guards), so a + /// concurrent streaming/permission/local-user/metadata change rejects the tentative result instead of + /// overwriting newer state. Rejection is cheap and does not require refetching the network page. + /// + internal bool TryCommitPreparedOlderMerge( + OlderMergeRoots roots, + ChatHistoryRebuildPlan olderPlan, + ChatTimelineState mergedTimeline, + System.Collections.Immutable.ImmutableDictionary mergedMetadata, + CancellationToken cancellationToken) + { + lock (_gate) + { + if (cancellationToken.IsCancellationRequested || _disposed) + return false; + var threadId = roots.ThreadId; + if (!_history.IsCurrent(roots.Lease.Token, GetResetVersionLocked(threadId), _disposed)) + return false; + if (_history.CaptureWindow( + threadId, + GetResetVersionLocked(threadId), + _disposed) is not { } current || + current.Revision != roots.Lease.Revision || + !Equals(current.Window, roots.Lease.Window)) + { + return false; + } + if (!SameIdentity(current.Window.SessionId, olderPlan.SessionId) || + !SameIdentity(current.Window.SessionId, roots.Window.SessionId) || + !string.Equals(roots.Window.SessionKey, threadId, StringComparison.Ordinal) || + roots.Window.ResponseOffset != roots.ExpectedOffset) + { + return false; + } + // EXACT captured-root compare: any timeline or metadata write since capture rejects. + if (!ReferenceEquals(GetOrCreateTimelineLocked(threadId), roots.Timeline)) + return false; + var metadataNow = _entryMeta.TryGetValue(threadId, out var map) + ? map + : System.Collections.Immutable.ImmutableDictionary.Empty; + if (!ReferenceEquals(metadataNow, roots.Metadata)) + return false; + if ((_retainedVersions.TryGetValue(threadId, out var rvNow) ? rvNow : 0) != roots.RetainedVersion) + return false; + + _timelines[threadId] = mergedTimeline; + _entryMeta[threadId] = mergedMetadata; // already immutable: O(1) assignment, no conversion under the gate + _history.MarkOlderCommitted(roots.Lease.Token, roots.Window); + BumpUsageRevisionLocked(threadId); + BumpRetainedVersionLocked(threadId); + return true; + } + } + + /// + /// Signals a retained usage/topology change (accepted history commit, older page, session usage). + /// Callers must NOT call this for text-only streaming edits. + /// + internal void NoteUsageTopologyChanged(string threadId) + { + lock (_gate) + BumpUsageRevisionLocked(threadId); + } + + /// Bumps the usage/topology revision. Callers must already hold _gate. + private void BumpUsageRevisionLocked(string threadId) => + _usageRevisions[threadId] = + (_usageRevisions.TryGetValue(threadId, out var current) ? current : 0) + 1; + + /// The accepted retained-transcript identity for a thread (null when none has been accepted). + internal string? GetAcceptedTranscriptId(string threadId) + { + lock (_gate) + return _history.GetAcceptedTranscriptId(threadId); + } + internal OpenClawChatDataProvider.LastChatState? RememberSelectedThread(string threadId) { lock (_gate) @@ -204,6 +551,19 @@ internal bool IsHistoryRequestCurrent(ChatHistoryCommitToken token) _disposed); } + /// + /// The accepted bounded page window for a thread, generation-validated against the current + /// reset/connection/replacement generations. Null when no valid window has been accepted. + /// + internal ChatHistoryWindowState? GetHistoryWindow(string threadId) + { + lock (_gate) + return _history.GetWindow( + threadId, + GetResetVersionLocked(threadId), + _disposed); + } + internal bool CanRetryHistory( ChatHistoryCommitToken token, bool authoritative) @@ -221,7 +581,8 @@ internal bool CommitHistory( ChatHistoryCommitToken token, ChatHistoryRebuildPlan plan, DateTimeOffset requestStartedAt, - bool authoritative) + bool authoritative, + ChatHistoryWindowState? window = null) { lock (_gate) { @@ -233,25 +594,98 @@ internal bool CommitHistory( return false; } - var prior = GetOrCreateTimelineLocked(token.ThreadId); - var priorMetadata = _entryMeta.TryGetValue( - token.ThreadId, - out var metadata) - ? metadata - : new Dictionary(); + // A bounded initial page is an INCOMPLETE tail. It must never take the authoritative + // replacement path, which would drop previously seen older entries. Only a complete + // window (or a caller that supplied no window) keeps the authoritative merge. + var effectiveAuthoritative = + authoritative && (window is null || !window.Value.HasMore); + + // Explicit initial-page identity replacement: if the accepted identity for this key changed + // (e.g. a new session UUID), drop the previous identity's entries instead of silently mixing + // two sessions under one key. Same-identity commits keep prior entries. + // Use the ACCEPTED history/window identity first, NOT the (possibly catalog-seeded) session + // id: ApplySessions can seed a new UUID before the first commit for a new transcript, which + // must not be mistaken for the identity of the retained timeline. An empty/failed catalog + // never clears anything (the fallback keeps the prior behaviour). + var acceptedSessionId = + _history.GetAcceptedTranscriptId(token.ThreadId) + ?? _history.GetWindow(token.ThreadId, GetResetVersionLocked(token.ThreadId), _disposed)?.SessionId + ?? _history.ResolveSessionId(token.ThreadId); + var identityReplaced = + !string.IsNullOrEmpty(plan.SessionId) && + !string.IsNullOrEmpty(acceptedSessionId) && + !string.Equals(plan.SessionId, acceptedSessionId, StringComparison.Ordinal); + var prior = identityReplaced + ? ChatTimelineState.Initial() + : GetOrCreateTimelineLocked(token.ThreadId); + var priorMetadata = identityReplaced + ? System.Collections.Immutable.ImmutableDictionary.Empty + : _entryMeta.TryGetValue( + token.ThreadId, + out var metadata) + ? metadata + : System.Collections.Immutable.ImmutableDictionary.Empty; var merged = ChatHistoryState.MergeWithLiveEntries( plan, prior, priorMetadata, requestStartedAt, - authoritative); + effectiveAuthoritative); _timelines[token.ThreadId] = merged.Timeline; - _entryMeta[token.ThreadId] = merged.Metadata; - _history.MarkCommitted(token, plan.SessionId); + _entryMeta[token.ThreadId] = System.Collections.Immutable.ImmutableDictionary.CreateRange(StringComparer.Ordinal, merged.Metadata); + // Window storage shares this token-validated, locked commit: a stale or failed + // reconstruction can never publish window metadata. + _history.MarkCommitted(token, plan.SessionId, window); + BumpUsageRevisionLocked(token.ThreadId); // accepted history commit changes usage topology + BumpRetainedVersionLocked(token.ThreadId); // ... and retained content return true; } } + /// + /// Atomic older-page commit. Identity-validated against the accepted window and the plan; merges + /// the older page into the held timeline (older entries prepended, replay overlap dropped, newer + /// and live entries preserved) and advances the window. A mismatched identity is rejected WITHOUT + /// clearing held state. + /// + /// + /// Atomically captures the accepted window with its token/revision so an older-page request can be + /// compared against it at commit time. + /// + internal bool TryCaptureHistoryWindowLease(string threadId, out ChatHistoryWindowLease lease) + { + lock (_gate) + { + if (_history.CaptureWindow( + threadId, + GetResetVersionLocked(threadId), + _disposed) is { } captured) + { + lease = captured; + return true; + } + lease = default; + return false; + } + } + + /// + /// Strict session identity: both sides must be a known, non-empty, ordinal-equal UUID. An unknown + /// or empty candidate against a known accepted identity is refused (unsupported/failure), never + /// admitted as verified identity. + /// + private static bool SameIdentity(string? accepted, string? candidate) => + !string.IsNullOrEmpty(accepted) && + !string.IsNullOrEmpty(candidate) && + string.Equals(accepted, candidate, StringComparison.Ordinal); + + /// The current model for a thread (used by the older-page reconstruction worker). + internal string? ModelForThread(string threadId) + { + lock (_gate) + return _presentation.ModelForThread(threadId); + } + internal ChatDataSnapshot? SnapshotIfHistoryTokenCurrent( ChatHistoryCommitToken token, ChatProjectionContext context) @@ -267,6 +701,40 @@ internal bool CommitHistory( } } + /// + /// SOURCE-OWNED delivery fence reused exactly by the WinUI provider consumer. Builds the snapshot + /// only when the generation token AND (when the result carries one) the FULL ORIGINAL WINDOW LEASE are + /// still current. A same-cursor refresh keeps the generation token but replaces the window revision, so + /// a generation token alone cannot fence an exhausted/error notification; the lease compare closes it. + /// The fence and the snapshot are one atomic observation under _gate (no second-read race). + /// + internal ChatDataSnapshot? SnapshotIfHistoryResultCurrent( + ChatHistoryLoadResult result, + ChatProjectionContext context) + { + lock (_gate) + { + return IsHistoryResultCurrentLocked(result) + ? BuildSnapshotLocked(context) + : null; + } + } + + private bool IsHistoryResultCurrentLocked(ChatHistoryLoadResult result) + { + var threadId = result.Token.ThreadId; + var reset = GetResetVersionLocked(threadId); + if (!_history.IsCurrent(result.Token, reset, _disposed)) + return false; + if (result.Lease is not { } lease) + return true; // no window authority attached: generation-token fence only + // FULL LEASE AUTHORITY: deliverable only while the accepted window is still exactly the original + // lease (revision + echoed window identity/cursor). + return _history.CaptureWindow(threadId, reset, _disposed) is { } current && + current.Revision == lease.Revision && + Equals(current.Window, lease.Window); + } + internal bool IsCurrentResetGeneration(string threadId, long generation) { lock (_gate) @@ -533,8 +1001,7 @@ private bool SnapshotLatestAssistantUsageLocked( { return false; } - metadata[timeline.Entries[i].Id] = - (existing ?? BuildLiveMetaLocked(threadId)) with + metadata = metadata.SetItem(timeline.Entries[i].Id, (existing ?? BuildLiveMetaLocked(threadId)) with { InputTokens = ToIntIfPositive(session.InputTokens), OutputTokens = ToIntIfPositive(session.OutputTokens), @@ -542,7 +1009,10 @@ private bool SnapshotLatestAssistantUsageLocked( ContextTokens = contextTokens, ContextPercent = existing?.ContextPercent, UsageContributionTokens = existing?.UsageContributionTokens, - }; + }); + _entryMeta[threadId] = metadata; // publish the new immutable snapshot (COW) + BumpUsageRevisionLocked(threadId); // session usage mutation, atomically with the write + BumpRetainedVersionLocked(threadId); // metadata-only writes must invalidate the merge snapshot return true; } return false; @@ -1007,11 +1477,12 @@ private ChatQueuedSendDispatch StartDirectSendLocked( current, request.EffectiveTimelineText, request.LocalNonce); - GetOrCreateThreadMetaLocked(threadId)[entryId] = BuildLiveMetaLocked( + var localMeta = GetOrCreateThreadMetaLocked(threadId); + _entryMeta[threadId] = localMeta.SetItem(entryId, BuildLiveMetaLocked( threadId, isLocalQueuedSend: true, localQueuedMessageId: request.Id, - attachments: request.AttachmentPresentations); + attachments: request.AttachmentPresentations)); var dispatch = _queue.StartDirect( request, _history.ResolveSessionId(threadId), @@ -1109,7 +1580,8 @@ private bool PromoteQueuedMessageLocked( isLocalQueuedSend: true, localQueuedMessageId: messageId, attachments: request?.AttachmentPresentations); - GetOrCreateThreadMetaLocked(threadId)[entryId] = meta; + var queuedMeta = GetOrCreateThreadMetaLocked(threadId); + _entryMeta[threadId] = queuedMeta.SetItem(entryId, meta); return true; } @@ -1178,7 +1650,8 @@ internal ChatEntryMetadata BuildLiveMetadata( long? compactionTokensBefore = null, long? compactionTokensAfter = null, IReadOnlyList? attachments = null, - ChatAssistantContentPresentation? assistantContent = null) + ChatAssistantContentPresentation? assistantContent = null, + string? gatewayDisplayItemId = null) { lock (_gate) { @@ -1193,7 +1666,8 @@ internal ChatEntryMetadata BuildLiveMetadata( compactionTokensBefore, compactionTokensAfter, attachments, - assistantContent); + assistantContent, + gatewayDisplayItemId: gatewayDisplayItemId); } } @@ -1374,7 +1848,8 @@ internal ChatIncomingMessageGate GateIncomingChatMessage( threadId, message.Ts, message.OpenClawId, - message.OpenClawSeq); + message.OpenClawSeq, + gatewayDisplayItemId: message.OpenClawDisplayItemId); if (ReconcileQueuedMessageEchoLocked( threadId, queuedMessageId, @@ -1429,7 +1904,8 @@ internal ChatLocalEchoTransition ConsumeLocalEcho( threadId, message.Ts, message.OpenClawId, - message.OpenClawSeq); + message.OpenClawSeq, + gatewayDisplayItemId: message.OpenClawDisplayItemId); return new( true, !removeQueuedMessage && @@ -1458,7 +1934,8 @@ internal ChatLocalEchoTransition ReconcileExistingLocalQueuedUser( message.Ts, message.OpenClawId, message.OpenClawSeq, - attachments: attachments); + attachments: attachments, + gatewayDisplayItemId: message.OpenClawDisplayItemId); if (TryReconcileExistingLocalQueuedUserEchoLocked( threadId, userText, @@ -1550,7 +2027,7 @@ internal ChatLocalEchoTransition ReconcileExistingLocalQueuedUser( if (mergedMeta == existingMeta) return null; - threadMeta[matched.Id] = mergedMeta; + _entryMeta[threadId] = threadMeta.SetItem(matched.Id, mergedMeta); return HasRendererVisibleUserMetadataChange(existingMeta, mergedMeta) ? BuildSnapshotLocked(context) : null; @@ -1632,7 +2109,8 @@ private static bool AttachmentPresentationsEqual( message.SessionKey!, message.Ts, message.OpenClawId, - message.OpenClawSeq); + message.OpenClawSeq, + gatewayDisplayItemId: message.OpenClawDisplayItemId); _lifecycle.TryGetActiveRun(message.SessionKey!, out var runId); return (metadata, runId); } @@ -1680,7 +2158,8 @@ message.OpenClawSeq is null message.Ts, message.OpenClawId, message.OpenClawSeq, - assistantContent: assistantContent); + assistantContent: assistantContent, + gatewayDisplayItemId: message.OpenClawDisplayItemId); var hasUsage = message.InputTokens is not null || message.OutputTokens is not null || message.ResponseTokens is not null || @@ -2109,7 +2588,8 @@ internal void CompleteRemoteBackfill(string threadId) message.Ts, message.OpenClawId, message.OpenClawSeq, - attachments: attachments); + attachments: attachments, + gatewayDisplayItemId: message.OpenClawDisplayItemId); var snapshot = ApplyProjectedRemoteUserMessageLocked( threadId, ChatContentFormatting.TruncateForChatEntry( @@ -2257,7 +2737,7 @@ private bool SnapshotAssistantUsageContributionLocked( { return false; } - threadMetadata[entry.Id] = (existing ?? BuildLiveMetaLocked(threadId)) with + threadMetadata = threadMetadata.SetItem(entry.Id, (existing ?? BuildLiveMetaLocked(threadId)) with { InputTokens = metadata.InputTokens ?? existing?.InputTokens, OutputTokens = metadata.OutputTokens ?? existing?.OutputTokens, @@ -2265,7 +2745,10 @@ private bool SnapshotAssistantUsageContributionLocked( ContextPercent = metadata.ContextPercent ?? existing?.ContextPercent, ContextTokens = contextTokens ?? existing?.ContextTokens, UsageContributionTokens = currentUsage, - }; + }); + _entryMeta[threadId] = threadMetadata; // publish the new immutable snapshot (COW) + BumpUsageRevisionLocked(threadId); // metadata mutation, atomically with the write + BumpRetainedVersionLocked(threadId); // metadata-only writes must invalidate the merge snapshot return true; } return false; @@ -2343,12 +2826,12 @@ private bool TryReconcileExistingLocalQueuedUserEchoLocked( var matched = candidates.First(candidate => string.Equals(candidate.Id, matchedMessageId, StringComparison.Ordinal)); var matchedMeta = metadata[matched.Id]; - metadata[matched.Id] = confirmed with + _entryMeta[threadId] = metadata.SetItem(matched.Id, confirmed with { IsLocalQueuedSend = false, LocalQueuedMessageId = matchedMeta.LocalQueuedMessageId, Attachments = matchedMeta.Attachments, - }; + }); return true; } @@ -2383,12 +2866,12 @@ private bool ReconcileQueuedMessageEchoLocked( StringComparison.Ordinal)); if (string.IsNullOrEmpty(match.Key)) return false; - metadata[match.Key] = confirmed with + _entryMeta[threadId] = metadata.SetItem(match.Key, confirmed with { IsLocalQueuedSend = false, LocalQueuedMessageId = messageId, Attachments = match.Value.Attachments, - }; + }); return true; } @@ -2533,10 +3016,10 @@ openClawSeq is not null && if (!string.IsNullOrEmpty(gatewayMessageId) && string.IsNullOrEmpty(existing.GatewayMessageId)) { - metadata[entry.Id] = existing with + _entryMeta[threadId] = metadata.SetItem(entry.Id, existing with { GatewayMessageId = gatewayMessageId, - }; + }); } return true; } @@ -2549,18 +3032,33 @@ private void ApplyEventLocked( ChatEvent evt, ChatEntryMetadata? metadata) { + // ANY ingest can change retained content/metadata: bump the exact merge version. + BumpRetainedVersionLocked(threadId); var current = GetOrCreateTimelineLocked(threadId); - var beforeIds = current.Entries.Select(entry => entry.Id) - .ToHashSet(StringComparer.Ordinal); + var currentCount = current.Entries.Count; var next = ChatTimelineReducer.Apply(current, evt); _timelines[threadId] = next; if (metadata is null) return; var threadMetadata = GetOrCreateThreadMetaLocked(threadId); - foreach (var entry in next.Entries) + // BOUNDED ingest bookkeeping: only entries APPENDED by this event can be new, so no id HashSet and + // no full retained scan under the lock. + var assignedNewEntry = false; + for (var i = currentCount; i < next.Entries.Count; i++) { - if (!beforeIds.Contains(entry.Id) && !threadMetadata.ContainsKey(entry.Id)) - threadMetadata[entry.Id] = metadata; + var appended = next.Entries[i]; + if (!threadMetadata.ContainsKey(appended.Id)) + { + threadMetadata = threadMetadata.SetItem(appended.Id, metadata); + assignedNewEntry = true; + } + } + // Selective invalidation: ONLY a newly ingested entry can change the usage topology. A streaming + // reconcile of an existing entry with UNCHANGED metadata must not reset a completed usage scan. + if (assignedNewEntry) + { + _entryMeta[threadId] = threadMetadata; // publish the new immutable snapshot (COW) + BumpUsageRevisionLocked(threadId); } // Streaming assistant frames reconcile into the SAME entry id @@ -2580,7 +3078,7 @@ private void ApplyEventLocked( if (entry.Kind != ChatTimelineItemKind.Assistant) continue; - if (beforeIds.Contains(entry.Id) && + if (i < currentCount && threadMetadata.TryGetValue(entry.Id, out var existingEntryMeta)) { var mergedContent = ChatAssistantContentProjector.MergeLiveUpdate( @@ -2588,10 +3086,10 @@ private void ApplyEventLocked( metadata.AssistantContent); if (!ReferenceEquals(mergedContent, existingEntryMeta.AssistantContent)) { - threadMetadata[entry.Id] = existingEntryMeta with - { - AssistantContent = mergedContent, - }; + threadMetadata = threadMetadata.SetItem( + entry.Id, + existingEntryMeta with { AssistantContent = mergedContent }); + _entryMeta[threadId] = threadMetadata; // publish the new immutable snapshot (COW) } } break; @@ -2599,12 +3097,13 @@ private void ApplyEventLocked( } } - private Dictionary GetOrCreateThreadMetaLocked( - string threadId) + private System.Collections.Immutable.ImmutableDictionary + GetOrCreateThreadMetaLocked(string threadId) { if (!_entryMeta.TryGetValue(threadId, out var metadata)) { - metadata = new Dictionary(StringComparer.Ordinal); + metadata = System.Collections.Immutable.ImmutableDictionary + .Empty.WithComparers(StringComparer.Ordinal); _entryMeta[threadId] = metadata; } return metadata; @@ -2621,7 +3120,10 @@ private ChatEntryMetadata BuildLiveMetaLocked( long? compactionTokensBefore = null, long? compactionTokensAfter = null, IReadOnlyList? attachments = null, - ChatAssistantContentPresentation? assistantContent = null) + ChatAssistantContentPresentation? assistantContent = null, + // Optional PROJECTED/DISPLAY item identity from the live gateway message, carried separately from + // the raw GatewayMessageId. Appended LAST so existing positional callers are unaffected. + string? gatewayDisplayItemId = null) { var timestamp = tsMs is { } value && value > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(value).ToLocalTime() @@ -2631,6 +3133,7 @@ private ChatEntryMetadata BuildLiveMetaLocked( _presentation.ModelForThread(threadId), GatewayMessageId: gatewayMessageId, OpenClawSeq: openClawSeq, + GatewayDisplayItemId: gatewayDisplayItemId, OpenClawKind: openClawKind, CompactionTokensBefore: compactionTokensBefore, CompactionTokensAfter: compactionTokensAfter, @@ -2694,6 +3197,7 @@ private ChatSnapshotProjectionInput CaptureProjectionInputLocked( timelineGenerations: _reset.SnapshotVersions(), historyRevisions: _history.SnapshotRevisions(), queuedMessages: _queue.SnapshotMessages(), + acceptedSessionIds: _history.SnapshotAcceptedTranscriptIds(), status: _status, context); diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs index eeabe0fcb7..2f5f64eb97 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs @@ -79,6 +79,8 @@ public sealed record ChatEntryMetadata( int? UsageContributionTokens = null, string? GatewayMessageId = null, int? OpenClawSeq = null, + // Optional PROJECTED/DISPLAY item identity (separate from the raw GatewayMessageId). Null for ordinary messages. + string? GatewayDisplayItemId = null, string? OpenClawKind = null, long? CompactionTokensBefore = null, long? CompactionTokensAfter = null, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs index b38b36f404..6906731bb1 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs @@ -10,7 +10,11 @@ namespace OpenClawTray.Chat; internal sealed record ChatHistoryLoadResult( ChatHistoryCommitToken Token, bool PublishSnapshot, - ChatProviderNotification? Notification = null); + ChatProviderNotification? Notification = null, + // The FULL ORIGINAL window lease this result was produced against (captured when the request began). + // Required to fence an exhausted/error notification at delivery time: a same-cursor refresh keeps the + // generation token but replaces the window revision, so the token alone cannot fence the notification. + ChatHistoryWindowLease? Lease = null); /// /// Owns history request lifetime, in-flight coalescing, cancellation, retries, @@ -26,6 +30,13 @@ private readonly record struct PendingReload( private const int MaxRetries = 3; private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2); + // ONE bounded initial page: explicit limit + maxBytes + offset 0 (the tail). The initial bounded + // tail is never treated as full authoritative complete history; completeness rides on the window + // committed atomically with the accepted timeline, and a later LoadOlder walks older pages. + // Never fetch all pages upfront. + internal const int InitialHistoryPageLimit = 200; + internal const int InitialHistoryPageMaxBytes = 512 * 1024; + private readonly object _gate = new(); private readonly IChatGatewayBridge _bridge; private readonly ChatConversationState _state; @@ -44,6 +55,7 @@ private readonly record struct PendingReload( private long _appliedStateGeneration; private long _requestSequence; private bool _disposed; + private readonly HashSet _olderInFlight = new(StringComparer.Ordinal); internal ChatHistoryLoader( IChatGatewayBridge bridge, @@ -69,6 +81,20 @@ internal ChatHistoryLoader( internal event EventHandler? Completed; + /// + /// Narrowly scoped test seam: awaited at the start of the reconstruction worker so a test can + /// deterministically hold actual reconstruction, advance/reset the generation, then release. + /// Null in production. + /// + internal Func? TestReconstructionBarrier { get; set; } + + /// + /// Production seam invoked AFTER a tentative older-page reconstruction and immediately BEFORE the + /// compare-and-commit, so tests can deterministically introduce a conflict at that exact point. + /// Null in production. + /// + internal Action? BeforeOlderMergeCommitForTests { get; set; } + internal Task LoadAsync( string threadId, bool force = false, @@ -115,6 +141,156 @@ internal Task LoadReplacementAsync( supersedeReplacement: true); } + /// + /// Loads ONE older bounded page for a thread using the accepted window's server-supplied + /// nextOffset (not rows.Count arithmetic). No eager loop and no unbounded fallback: a missing or + /// exhausted window is a no-op. The accepted window is committed atomically by the state. + /// + internal async Task LoadOlderAsync(string threadId, CancellationToken cancellationToken = default) + { + CancellationToken generationToken; + lock (_gate) + { + if (_disposed || !_olderInFlight.Add(threadId)) + return; // single-flight per thread + generationToken = _generationCancellation.Token; + } + + try + { + if (!_state.TryCaptureOlderMergeLease(threadId, out var lease, out var retainedLeaseVersion)) + return; + if (!lease.Window.HasMore || lease.Window.NextOffset is not int nextOffset) + return; + + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, generationToken); + // Entry check: a pre-cancelled request must not run ANY reconstruction stage (not even the + // fetch), so cancellation is observed before real work begins. + linked.Token.ThrowIfCancellationRequested(); + var model = _state.ModelForThread(threadId); + var page = await _bridge.RequestChatHistoryPageAsync( + threadId, + new ChatHistoryPageOptions( + Limit: InitialHistoryPageLimit, + MaxBytes: InitialHistoryPageMaxBytes, + Offset: nextOffset), + linked.Token) + .WaitAsync(linked.Token) + .ConfigureAwait(false); + + if (_disposed || linked.IsCancellationRequested || + !_state.IsHistoryRequestCurrent(lease.Token)) + return; + + var history = new ChatHistoryInfo + { + SessionKey = page.SessionKey, + SessionId = page.SessionId, + Messages = page.Messages, + }; + var windowState = new ChatHistoryWindowState( + page.SessionKey, page.SessionId, page.HasMore, page.NextOffset, page.Total, page.ResponseOffset, + page.Completeness, page.OmittedCount, page.NormalizedBytes); + + // BOUNDED content-root REBASE: the network page is fetched ONCE and the page plan is built once + // per attempt; each attempt re-captures only the CHEAP content roots (timeline + immutable + // metadata references) and reconstructs off-lock, and the commit rejects when a streaming/ + // metadata/permission edit landed in the meantime. Up to 3 attempts; the ORIGINAL network lease + // stays authoritative throughout. + // The page plan is INVARIANT across rebase attempts: build it ONCE (outside the loop). + linked.Token.ThrowIfCancellationRequested(); + var plan = await Task.Run( + () => BuildPlanAsync( + history, + threadId, + model, + lease.Token.ResetGeneration, + linked.Token), + linked.Token) + .ConfigureAwait(false); + + // Bounded content-root rebase: up to 3 reconstructions on the newest cheap roots. + const int maxAttempts = 3; + var committed = false; + var commitToken = lease.Token; + for (var attempt = 0; attempt < maxAttempts && !committed; attempt++) + { + linked.Token.ThrowIfCancellationRequested(); // entry check: a pre-cancelled pass never runs + if (!_state.TryPrepareOlderMergeRoots(threadId, nextOffset, windowState, lease, out var roots)) + return; // original lease no longer authoritative: reject silently (no stale notification) + commitToken = roots.Lease.Token; + + if (_disposed || linked.IsCancellationRequested) + return; + + var tentative = await Task.Run( + () => + { + var merged = ChatHistoryState.MergeOlderPage( + plan, roots.Timeline, roots.Metadata, linked.Token); + // Bounded/cancellable immutable map conversion OFF the gate: the commit stays an + // O(1) assignment and the final map build is itself cancellable every 64 entries + // (no full scan swapped onto the UI thread). + return ( + Timeline: merged.Timeline, + Metadata: ChatHistoryState.ToImmutableMetadataCancellable( + merged.Metadata, linked.Token)); + }, + linked.Token) + .ConfigureAwait(false); + + if (_disposed || linked.IsCancellationRequested) + return; + + // Production seam AFTER a completed tentative reconstruction and BEFORE the commit, so a + // test can deterministically introduce a conflict at exactly this point. + BeforeOlderMergeCommitForTests?.Invoke(); + linked.Token.ThrowIfCancellationRequested(); + committed = _state.TryCommitPreparedOlderMerge( + roots, + plan, + tentative.Timeline, + tentative.Metadata, + linked.Token); + } + + if (!committed) + { + // Sustained content churn exhausted the bounded rebase: surface a VISIBLE retry/pending state + // through the existing notification mechanism instead of silently dropping the page. + Completed?.Invoke( + this, + new ChatHistoryLoadResult( + commitToken, + PublishSnapshot: false, + new ChatProviderNotification( + ChatProviderNotificationKind.Error, + threadId, + LocalizationHelper.GetString("Chat_Notification_LoadHistoryFailed"), + LocalizationHelper.GetString("Chat_Notification_LoadHistoryFailed")), + // FULL LEASE AUTHORITY: the ORIGINAL network lease, not the (possibly refreshed) + // per-attempt roots lease. Exhaustion is only deliverable while that exact window + // revision is still accepted. + Lease: lease)); + return; + } + + Completed?.Invoke( + this, + new ChatHistoryLoadResult(commitToken, PublishSnapshot: true)); + } + catch (OperationCanceledException) + { + // Cancellation/disposal/generation change: held timeline and accepted window untouched. + } + finally + { + lock (_gate) + _olderInFlight.Remove(threadId); + } + } + internal ChatStatusTransition ApplyStatusAndAdvanceGeneration( ConnectionStatus status, ChatProjectionContext context) @@ -308,29 +484,56 @@ await LoadCoreAsync( force ? ChatHistoryTelemetrySource.Forced : ChatHistoryTelemetrySource.Initial); var outcome = ChatTelemetryOutcome.Success; Exception? failure = null; - Task? request = null; + Task? request = null; try { - request = _bridge.RequestChatHistoryAsync(threadId); - var history = await request + // ONE bounded initial page through the real production page path (no legacy unbounded + // fallback). The window is stored atomically with the accepted commit below, so a stale or + // failed reconstruction can never publish it. + request = _bridge.RequestChatHistoryPageAsync( + threadId, + new ChatHistoryPageOptions( + Limit: InitialHistoryPageLimit, + MaxBytes: InitialHistoryPageMaxBytes, + Offset: 0), + linkedCancellation.Token); + var page = await request .WaitAsync(linkedCancellation.Token) .ConfigureAwait(false); + var history = new ChatHistoryInfo + { + SessionKey = page.SessionKey, + SessionId = page.SessionId, + Messages = page.Messages, + }; if (!_state.IsHistoryRequestCurrent(commitToken)) { outcome = ChatTelemetryOutcome.Canceled; return; } - var plan = BuildPlan( - history, - threadId, - model, - commitToken.ResetGeneration); + // Explicit WHOLE-reconstruction worker: the entire plan build (ordering, projection, + // reserved scan, fold) runs on the pool, so it does not continue inline on the caller + // even when the history task or an earlier precompute await already completed. + var plan = await Task.Run( + () => BuildPlanAsync( + history, + threadId, + model, + commitToken.ResetGeneration, + linkedCancellation.Token), + linkedCancellation.Token) + .ConfigureAwait(false); + // Pass the actual page so its window metadata is stored atomically with the accepted + // timeline commit (never before the current-token check; no stale write possible). var committed = _state.CommitHistory( commitToken, plan, requestStartedAt, - authoritative); + authoritative, + new ChatHistoryWindowState( + page.SessionKey, page.SessionId, page.HasMore, page.NextOffset, page.Total, page.ResponseOffset, + page.Completeness, page.OmittedCount, page.NormalizedBytes)); if (!committed) { outcome = ChatTelemetryOutcome.Canceled; @@ -338,11 +541,21 @@ await LoadCoreAsync( } lock (_gate) _retryCounts.Remove(commitToken); + // ACTUAL reported omission from the source: the received content is committed (valid rows preserved + // incrementally) and a VISIBLE partial state is surfaced so the retained transcript is never presented + // as a complete snapshot. The reported loss is never turned into invented rows. Completed?.Invoke( this, new ChatHistoryLoadResult( commitToken, - PublishSnapshot: true)); + PublishSnapshot: true, + page.ContentLossReported + ? new ChatProviderNotification( + ChatProviderNotificationKind.Error, + threadId, + LocalizationHelper.GetString("Chat_Notification_LoadHistoryFailed"), + "chat.history reported omitted content; the retained transcript shows only the received rows.") + : null)); } catch (OperationCanceledException) { @@ -450,12 +663,19 @@ await LoadCoreAsync( .ConfigureAwait(false); } - private ChatHistoryRebuildPlan BuildPlan( + private async Task BuildPlanAsync( ChatHistoryInfo history, string threadId, string? model, - long resetGeneration) + long resetGeneration, + CancellationToken cancellationToken) { + // Test-only barrier: runs inside the reconstruction worker (see TestReconstructionBarrier). + if (TestReconstructionBarrier is { } barrier) + await barrier().ConfigureAwait(false); + + // GetToolMetadata / CreateAttachmentMatcher are independent, lock-guarded copies; obtained + // here inside the worker (no invented UI-affinity restriction). var timeline = ChatTimelineState.Initial() with { HistoryLoaded = true }; var metadata = new Dictionary(StringComparer.Ordinal); var cachedTools = _metadata.GetToolMetadata( @@ -510,14 +730,52 @@ next.ActiveAssistantId is { } assistantId && return next; } - var orderedMessages = OrderHistoryMessages(history.Messages); - var replayParts = - ChatHistoryReplayProjection.Project(orderedMessages).ToArray(); - var reservedToolCallIds = replayParts - .SelectMany(static part => part.ToolContent) - .Select(static tool => tool.CallId) - .Where(static callId => !string.IsNullOrWhiteSpace(callId)) - .ToHashSet(StringComparer.Ordinal); + // The pre-yield ordering/projection/reserved-ID scan is synchronous work that Task.Yield + // cannot bound; run it on a worker with a linked cancellation token so a superseded or + // cancelled build stops promptly instead of monopolising the caller (UI) thread. + cancellationToken.ThrowIfCancellationRequested(); + (ChatHistoryReplayPart[] Parts, HashSet Reserved) precomputed; + try + { + precomputed = await Task.Run( + () => + { + cancellationToken.ThrowIfCancellationRequested(); + var orderedMessages = OrderHistoryMessages(history.Messages, cancellationToken); + var parts = ChatHistoryReplayProjection.Project(orderedMessages, cancellationToken).ToArray(); + cancellationToken.ThrowIfCancellationRequested(); + // Check WHILE enumerating the reserved tool IDs, not only around the ToHashSet. + var reserved = new HashSet(StringComparer.Ordinal); + var sinceToolCheck = 0; + foreach (var part in parts) + { + // Count ACTUAL tool entries (inner loop), not outer parts only. + foreach (var tool in part.ToolContent) + { + if (++sinceToolCheck >= OpenClaw.Shared.HistoryPagingBudget.DefaultItemsPerTick) + { + sinceToolCheck = 0; + cancellationToken.ThrowIfCancellationRequested(); + } + if (!string.IsNullOrWhiteSpace(tool.CallId)) + reserved.Add(tool.CallId); + } + } + return (parts, reserved); + }, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or AggregateException) + { + // LINQ/List.Sort can wrap a comparer-thrown OperationCanceledException; surface the real + // cancellation so it is not reported as a load failure/retry. + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + var (replayParts, reservedToolCallIds) = precomputed; + + // Post-worker / pre-fold check: a build superseded while the worker ran must not start the fold. + cancellationToken.ThrowIfCancellationRequested(); string AllocateSyntheticToolCallId() { @@ -579,8 +837,17 @@ string AllocateSyntheticToolCallId() return lookup.Match; } + var partsSinceYield = 0; foreach (var replayPart in replayParts) { + if (++partsSinceYield >= OpenClaw.Shared.HistoryPagingBudget.DefaultItemsPerTick) + { + partsSinceYield = 0; + // Finite interval: a superseded/cancelled build stops at the next interval instead + // of running the whole fold to completion. + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } var message = replayPart.Message; if (suppressedAbortedAssistant is not null) { @@ -605,6 +872,7 @@ string AllocateSyntheticToolCallId() message.ContextPercent, GatewayMessageId: message.OpenClawId, OpenClawSeq: message.OpenClawSeq, + GatewayDisplayItemId: message.OpenClawDisplayItemId, OpenClawKind: message.OpenClawKind, CompactionTokensBefore: message.CompactionTokensBefore, CompactionTokensAfter: message.CompactionTokensAfter, @@ -939,6 +1207,8 @@ string AllocateSyntheticToolCallId() .Select(message => message.OpenClawSeq!.Value) .DefaultIfEmpty(int.MinValue) .Max(); + // Final pre-return check: a build cancelled during the fold must not publish a plan. + cancellationToken.ThrowIfCancellationRequested(); return new(history.SessionId, timeline, metadata, maxSequence); } @@ -969,34 +1239,78 @@ private void CompleteInFlight( } } - private static List OrderHistoryMessages( - IReadOnlyList messages) + internal static List OrderHistoryMessages( + IReadOnlyList messages, + CancellationToken cancellationToken, + Action? comparisonObserver = null, + int? comparisonInterval = null) { - var indexed = messages - .Select((message, index) => (Message: message, Index: index)) - .ToList(); - var sequencedCount = indexed.Count(item => - item.Message.OpenClawSeq is not null); - if (sequencedCount == indexed.Count) + // Finite, cheap checks only (no per-comparison synchronization). Ordering semantics are + // unchanged: seq when all sequenced, timestamp when none sequenced, insertion order when + // mixed, with the original index as the tiebreaker. + cancellationToken.ThrowIfCancellationRequested(); + var interval = OpenClaw.Shared.HistoryPagingBudget.DefaultItemsPerTick; + var sinceCheck = 0; + void Tick() + { + if (++sinceCheck >= interval) + { + sinceCheck = 0; + cancellationToken.ThrowIfCancellationRequested(); + } + } + + var indexed = new List<(ChatMessageInfo Message, int Index)>(messages.Count); + for (var i = 0; i < messages.Count; i++) { - return indexed - .OrderBy(item => item.Message.OpenClawSeq) - .ThenBy(item => item.Index) - .Select(item => item.Message) - .ToList(); + Tick(); + indexed.Add((messages[i], i)); } - if (sequencedCount == 0) + + // Sequenced-count scan: finite checks while scanning, not only around it. + var sequencedCount = 0; + for (var i = 0; i < indexed.Count; i++) { - return indexed - .OrderBy(item => item.Message.Ts) - .ThenBy(item => item.Index) - .Select(item => item.Message) - .ToList(); + Tick(); + if (indexed[i].Message.OpenClawSeq is not null) sequencedCount++; } - return indexed - .OrderBy(item => item.Index) - .Select(item => item.Message) - .ToList(); + + // Explicit comparer (not LINQ): preserves the original-index tiebreaker and checks the linked + // token at a FINITE COMPARISON interval. No per-comparison locking/synchronization. + var mode = sequencedCount == indexed.Count ? 0 : sequencedCount == 0 ? 1 : 2; + var compareInterval = comparisonInterval is > 0 ? comparisonInterval.Value : interval; + var comparisons = 0; + int Compare((ChatMessageInfo Message, int Index) a, (ChatMessageInfo Message, int Index) b) + { + comparisons++; + comparisonObserver?.Invoke(comparisons); + if (comparisons % compareInterval == 0) + cancellationToken.ThrowIfCancellationRequested(); + var primary = mode switch + { + 0 => System.Nullable.Compare(a.Message.OpenClawSeq, b.Message.OpenClawSeq), + 1 => a.Message.Ts.CompareTo(b.Message.Ts), + _ => 0, + }; + return primary != 0 ? primary : a.Index.CompareTo(b.Index); + } + + // List.Sort wraps a comparer-thrown exception in InvalidOperationException; surface the real + // cancellation here (and again at the loader boundary). + try + { + indexed.Sort(Compare); + } + catch (Exception ex) when (ex is InvalidOperationException or AggregateException) + { + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + cancellationToken.ThrowIfCancellationRequested(); + var ordered = new List(indexed.Count); + for (var i = 0; i < indexed.Count; i++) + ordered.Add(indexed[i].Message); + return ordered; } private static async Task ObserveRetryAsync(Task task) diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs index 96aa024304..95d0cdaa29 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs @@ -30,10 +30,24 @@ internal static string ToolLabel(string toolName, JsonObject? args) } public static IEnumerable Project( - IEnumerable messages) + IEnumerable messages, + CancellationToken cancellationToken = default) { + // Finite in-enumeration checks counted by ACTUAL yielded parts (not outer messages only), + // checked while yielding rather than only around the caller's ToArray. + var interval = OpenClaw.Shared.HistoryPagingBudget.DefaultItemsPerTick; + var sincePartCheck = 0; + void TickPart() + { + if (++sincePartCheck >= interval) + { + sincePartCheck = 0; + cancellationToken.ThrowIfCancellationRequested(); + } + } foreach (var message in messages) { + TickPart(); if (message.ContentParts.Count == 0) { yield return new ChatHistoryReplayPart( @@ -48,6 +62,7 @@ public static IEnumerable Project( var isFirstPart = true; foreach (var part in message.ContentParts) { + TickPart(); // count ACTUAL parts, not outer messages if (part.Kind == ChatMessageContentPartKind.Text) { yield return new ChatHistoryReplayPart( diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs index 0ab9fdabcd..7395184845 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs @@ -4,6 +4,35 @@ namespace OpenClawTray.Chat; +/// +/// State-owned bounded chat.history window carried from the last ACCEPTED commit: echoed session +/// identity, whether older pages remain, the next older offset, and the source total. Generation +/// validity comes from the commit token stored alongside it; the window is cleared on reset, +/// reconnect, replacement and dispose so a stale window can never be retrieved. +/// +internal readonly record struct ChatHistoryWindowState( + string SessionKey, + string? SessionId, + bool HasMore, + int? NextOffset, + int? Total, + int? ResponseOffset, + // SOURCE-GROUNDED completeness carried with the accepted window: lets the actual caller/UI distinguish a + // snapshot-explicit-complete page, an ordinary page (not loss), and an ACTUAL reported omission. + ChatHistoryPageCompleteness Completeness = ChatHistoryPageCompleteness.UnknownLegacy, + int? OmittedCount = null, + long? NormalizedBytes = null); + +/// +/// Atomic capture of the accepted window together with its commit token and revision. A commit that +/// carries a lease is accepted only while the currently stored window is still exactly this one, so a +/// delayed older-page response can never regress a newer accepted cursor in the same generation. +/// +internal readonly record struct ChatHistoryWindowLease( + ChatHistoryWindowState Window, + ChatHistoryCommitToken Token, + long Revision); + /// /// Owns session identity, transcript freshness/revisions, and the single /// connection-generation activation/commit-token state. The root supplies @@ -16,6 +45,13 @@ internal sealed class ChatHistoryState private readonly Dictionary _revisions = new(); private readonly Dictionary _resetClearedSessionIds = new(); private readonly Dictionary _replacementGenerations = new(); + private readonly Dictionary _historyWindows = + new(StringComparer.Ordinal); + + // ACCEPTED RETAINED TRANSCRIPT identity, deliberately separate from the catalog/send address. It + // survives paging-window invalidation (disconnect/reconnect) while the retained timeline survives, + // and changes only on an accepted history commit or an explicit timeline reset/replacement. + private readonly Dictionary _acceptedTranscriptIds = new(StringComparer.Ordinal); private long _connectionGeneration; private bool _generationReady = true; @@ -24,6 +60,17 @@ internal sealed class ChatHistoryState internal long ConnectionGeneration => _connectionGeneration; + /// + /// The accepted retained-transcript identity for a thread (survives window invalidation), or null + /// when no accepted commit has established one. + /// + /// Coherent copy of the accepted session UUIDs, captured under the SAME lock as the timeline. + internal IReadOnlyDictionary SnapshotAcceptedTranscriptIds() => + new Dictionary(_acceptedTranscriptIds, StringComparer.Ordinal); + + internal string? GetAcceptedTranscriptId(string threadId) => + _acceptedTranscriptIds.TryGetValue(threadId, out var id) ? id : null; + internal string? ResolveSessionId(string threadId) => _sessionIds.TryGetValue(threadId, out var sessionId) ? sessionId @@ -48,6 +95,8 @@ internal ChatHistoryCommitToken BeginReplacement( _replacementGenerations[threadId] = GetReplacementGeneration(threadId) + 1; _loadedThreads.Remove(threadId); + _historyWindows.Remove(threadId); + _acceptedTranscriptIds.Remove(threadId); // explicit replacement drops the transcript identity return CreateCommitToken(threadId, resetGeneration); } @@ -102,7 +151,8 @@ internal bool CanRetry( internal void MarkCommitted( ChatHistoryCommitToken token, - string? sessionId) + string? sessionId, + ChatHistoryWindowState? window = null) { if (!string.IsNullOrEmpty(sessionId)) _sessionIds[token.ThreadId] = sessionId; @@ -111,6 +161,52 @@ internal void MarkCommitted( ? revision : 0) + 1; _loadedThreads.Add(token.ThreadId); + if (!string.IsNullOrEmpty(sessionId)) + _acceptedTranscriptIds[token.ThreadId] = sessionId; + if (window is { } acceptedWindow) + _historyWindows[token.ThreadId] = ( + acceptedWindow, + token, + _revisions[token.ThreadId]); + } + + /// + /// The window accepted by the most recent current commit for a thread, or null when there is no + /// valid window (never committed, superseded by reset/reconnect/replacement, or disposed). + /// + internal ChatHistoryWindowState? GetWindow( + string threadId, + long resetGeneration, + bool disposed) => + CaptureWindow(threadId, resetGeneration, disposed)?.Window; + + /// + /// Atomically captures the accepted window with its commit token and revision, or null when there + /// is no valid window (never committed, superseded, or disposed). + /// + internal ChatHistoryWindowLease? CaptureWindow( + string threadId, + long resetGeneration, + bool disposed) => + !disposed && + _historyWindows.TryGetValue(threadId, out var stored) && + IsCurrent(stored.Token, resetGeneration, disposed) + ? new ChatHistoryWindowLease(stored.Window, stored.Token, stored.Revision) + : null; + + /// + /// Advances the accepted window for an OLDER page without touching session identity (unlike + /// , which owns identity). Bumps the revision so consumers republish. + /// + internal void MarkOlderCommitted( + ChatHistoryCommitToken token, + ChatHistoryWindowState window) + { + _revisions[token.ThreadId] = + (_revisions.TryGetValue(token.ThreadId, out var revision) + ? revision + : 0) + 1; + _historyWindows[token.ThreadId] = (window, token, _revisions[token.ThreadId]); } internal long AdvanceConnectionGeneration(bool clearLoaded) @@ -122,6 +218,7 @@ internal long AdvanceConnectionGeneration(bool clearLoaded) TaskCreationOptions.RunContinuationsAsynchronously); if (clearLoaded) _loadedThreads.Clear(); + _historyWindows.Clear(); return _connectionGeneration; } @@ -148,6 +245,8 @@ internal void ActivateConnectionGeneration( _resetClearedSessionIds.Remove(threadId); _sessionIds.Remove(threadId); _loadedThreads.Add(threadId); + _historyWindows.Remove(threadId); + _acceptedTranscriptIds.Remove(threadId); // explicit timeline reset drops the transcript identity return oldSessionId; } @@ -205,13 +304,13 @@ static string SequenceKey(ChatTimelineItemKind kind, int sequence) => var contentTimestamps = new Dictionary>( StringComparer.Ordinal); - var messageIds = new HashSet(StringComparer.Ordinal); + var messageIds = new HashSet<(string RawId, string? DisplayItemId)>(); var sequenceCounts = new Dictionary(StringComparer.Ordinal); foreach (var entry in rebuilt.Entries) { rebuiltMetadata.TryGetValue(entry.Id, out var metadata); if (!string.IsNullOrEmpty(metadata?.GatewayMessageId)) - messageIds.Add(metadata.GatewayMessageId); + messageIds.Add((metadata.GatewayMessageId, metadata.GatewayDisplayItemId)); if (metadata?.OpenClawSeq is { } sequence) IncrementCount(sequenceCounts, SequenceKey(entry.Kind, sequence)); if (metadata?.Timestamp is { } timestamp && timestamp != default) @@ -244,14 +343,15 @@ static string SequenceKey(ChatTimelineItemKind kind, int sequence) => { priorMetadata.TryGetValue(entry.Id, out var metadata); if (!string.IsNullOrEmpty(metadata?.GatewayMessageId) && - messageIds.Contains(metadata.GatewayMessageId)) + messageIds.Contains((metadata.GatewayMessageId, metadata.GatewayDisplayItemId))) { ConsumeAnyTimestamp( contentTimestamps, ContentKey(entry.Kind, entry.Text)); continue; } - if (metadata?.OpenClawSeq is { } sequence && + if (string.IsNullOrEmpty(metadata?.GatewayMessageId) && + metadata?.OpenClawSeq is { } sequence && TryConsumeCount( sequenceCounts, SequenceKey(entry.Kind, sequence))) @@ -269,7 +369,8 @@ static string SequenceKey(ChatTimelineItemKind kind, int sequence) => { continue; } - if (metadata?.Timestamp is { } timestamp && + if (string.IsNullOrEmpty(metadata?.GatewayMessageId) && + metadata?.Timestamp is { } timestamp && timestamp != default && contentTimestamps.TryGetValue( ContentKey(entry.Kind, entry.Text), @@ -307,7 +408,7 @@ static string SequenceKey(ChatTimelineItemKind kind, int sequence) => timestamps.Add(addedTimestamp.ToUnixTimeSeconds()); } if (!string.IsNullOrEmpty(metadata?.GatewayMessageId)) - messageIds.Add(metadata.GatewayMessageId); + messageIds.Add((metadata.GatewayMessageId, metadata.GatewayDisplayItemId)); if (metadata?.OpenClawSeq is { } addedSequence) { IncrementCount( @@ -337,6 +438,171 @@ static string SequenceKey(ChatTimelineItemKind kind, int sequence) => return (merged, rebuiltMetadata); } + /// + /// Prepends an OLDER bounded page to the held timeline: entries already present (replay overlap) are + /// dropped, surviving older entries are renumbered and placed before the held newer entries, and + /// metadata is unioned. Session identity is not changed here. + /// + internal static ( + ChatTimelineState Timeline, + Dictionary Metadata) + MergeOlderPage( + ChatHistoryRebuildPlan older, + ChatTimelineState existing, + IReadOnlyDictionary existingMetadata, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); // stage entry (before any real work) + // BOUNDED cancellable copies: each stage checks the token every 64 real entries so a large + // full-history metadata copy cannot run to completion after cancellation. + var olderMetadata = CopyMetadataCancellable(older.Metadata, cancellationToken); + var mergedMetadata = CopyMetadataCancellable(existingMetadata, cancellationToken); + + static string OlderContentKey(ChatTimelineItemKind kind, string text) => $"{kind}|{text}"; + static string OlderSequenceKey(ChatTimelineItemKind kind, int sequence) => $"{kind}|{sequence}"; + + var messageIds = new HashSet<(string RawId, string? DisplayItemId)>(); + var sequenceCounts = new Dictionary(StringComparer.Ordinal); + var contentTimestamps = new Dictionary>(StringComparer.Ordinal); + var maxSuffix = 0; + var visitedExisting = 0; + foreach (var entry in existing.Entries) + { + // Bounded cancellation responsiveness INSIDE a potentially long full-history scan. + if ((++visitedExisting & 63) == 0) + cancellationToken.ThrowIfCancellationRequested(); + existingMetadata.TryGetValue(entry.Id, out var metadata); + if (!string.IsNullOrEmpty(metadata?.GatewayMessageId)) + messageIds.Add((metadata.GatewayMessageId, metadata.GatewayDisplayItemId)); + if (metadata?.OpenClawSeq is { } sequence) + IncrementCount(sequenceCounts, OlderSequenceKey(entry.Kind, sequence)); + // Content+timestamp fallback is registered ONLY for existing entries with no source + // identity, so a fuzzy match can never erase a distinct known-ID/sequence row. + var existingHasIdentity = + !string.IsNullOrEmpty(metadata?.GatewayMessageId) || metadata?.OpenClawSeq is not null; + if (!existingHasIdentity && metadata?.Timestamp is { } timestamp && timestamp != default) + { + var key = OlderContentKey(entry.Kind, entry.Text); + if (!contentTimestamps.TryGetValue(key, out var times)) + { + times = []; + contentTimestamps[key] = times; + } + times.Add(timestamp.ToUnixTimeSeconds()); + } + if (entry.Id.Length > 1 && entry.Id[0] == 'e' && + int.TryParse(entry.Id.AsSpan(1), out var suffix) && suffix > maxSuffix) + maxSuffix = suffix; + } + + var nextId = Math.Max(existing.NextId, maxSuffix + 1); + var builder = existing.Entries.ToBuilder(); + var inserted = 0; + var visitedOlder = 0; + foreach (var entry in older.Timeline.Entries) + { + if ((++visitedOlder & 63) == 0) + cancellationToken.ThrowIfCancellationRequested(); + olderMetadata.TryGetValue(entry.Id, out var metadata); + if (!string.IsNullOrEmpty(metadata?.GatewayMessageId) && + messageIds.Contains((metadata.GatewayMessageId, metadata.GatewayDisplayItemId))) + { + ConsumeAnyTimestamp(contentTimestamps, OlderContentKey(entry.Kind, entry.Text)); + continue; + } + // IDENTITY PRECEDENCE: the seq fallback applies ONLY to an entry with NO known source id. A distinct + // KNOWN GatewayMessageId must SURVIVE even when it shares kind/seq/content with an inserted sibling. + var olderHasKnownId = !string.IsNullOrEmpty(metadata?.GatewayMessageId); + if (!olderHasKnownId && + metadata?.OpenClawSeq is { } sequence && + TryConsumeCount(sequenceCounts, OlderSequenceKey(entry.Kind, sequence))) + { + ConsumeAnyTimestamp(contentTimestamps, OlderContentKey(entry.Kind, entry.Text)); + continue; + } + var olderHasIdentity = + !string.IsNullOrEmpty(metadata?.GatewayMessageId) || metadata?.OpenClawSeq is not null; + if (!olderHasIdentity && + metadata?.Timestamp is { } timestamp && timestamp != default && + contentTimestamps.TryGetValue(OlderContentKey(entry.Kind, entry.Text), out var times)) + { + var seconds = timestamp.ToUnixTimeSeconds(); + var match = times.FindIndex(value => Math.Abs(value - seconds) <= 2); + if (match >= 0) + { + times.RemoveAt(match); + continue; + } + } + + var added = entry with { Id = $"e{nextId++}" }; + builder.Insert(inserted++, added); + if (metadata is not null) + mergedMetadata[added.Id] = metadata; + if (!string.IsNullOrEmpty(metadata?.GatewayMessageId)) + messageIds.Add((metadata.GatewayMessageId, metadata.GatewayDisplayItemId)); + if (metadata?.OpenClawSeq is { } addedSequence) + IncrementCount(sequenceCounts, OlderSequenceKey(added.Kind, addedSequence)); + } + + cancellationToken.ThrowIfCancellationRequested(); // stage boundary before the immutable build + var timeline = existing with { Entries = builder.ToImmutable(), NextId = nextId }; + // Bounded cancellation INSIDE the final active-tool-tracking rebuild (entry/exit + every 64). + timeline = ChatTimelineReducer.RebuildActiveToolTracking(timeline, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); // stage exit before returning the plan + return (timeline, mergedMetadata); + } + + /// + /// Bounded, cancellable copy of a metadata map: checks the token every 64 real entries so a large + /// full-history copy cannot run to completion after cancellation. Preserves the exact entry sequence. + /// + internal static Dictionary CopyMetadataCancellable( + IEnumerable> source, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); // stage entry + var capacity = source is IReadOnlyCollection> counted + ? counted.Count + : 0; + var copy = new Dictionary(capacity, StringComparer.Ordinal); + var visited = 0; + foreach (var pair in source) + { + if ((++visited & 63) == 0) + cancellationToken.ThrowIfCancellationRequested(); + copy[pair.Key] = pair.Value; + } + // Stage exit: a copy shorter than the 64-entry checkpoint interval still observes a cancellation + // that landed during enumeration instead of returning normally. + cancellationToken.ThrowIfCancellationRequested(); + return copy; + } + + /// + /// Bounded, cancellable conversion of a metadata map to an IMMUTABLE map (the O(1) commit payload). + /// Checks the token every 64 real entries plus entry/exit, so the final immutable-map construction is + /// itself cancellable without a full scan on the UI thread. + /// + internal static System.Collections.Immutable.ImmutableDictionary + ToImmutableMetadataCancellable( + IEnumerable> source, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); // stage entry + var builder = System.Collections.Immutable.ImmutableDictionary + .CreateBuilder(StringComparer.Ordinal); + var visited = 0; + foreach (var pair in source) + { + if ((++visited & 63) == 0) + cancellationToken.ThrowIfCancellationRequested(); + builder[pair.Key] = pair.Value; + } + cancellationToken.ThrowIfCancellationRequested(); // stage exit + return builder.ToImmutable(); + } + internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload( ChatEntryMetadata? metadata, int maxHistorySequence, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatLatestUsageProjection.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatLatestUsageProjection.cs new file mode 100644 index 0000000000..0a615dae45 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatLatestUsageProjection.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using OpenClaw.Chat; + +namespace OpenClawTray.Chat; + +/// +/// Maintains the LATEST QUALIFYING assistant usage over the FULL retained timeline, so windowing the +/// display never loses it (the old formatter scanned the entries it was given; a 400-entry window could +/// hide a qualifying assistant). Semantics match ChatUsageFormatter.Format(entries, metadata): walk +/// newest -> oldest and return the first Assistant entry whose metadata yields a non-empty summary. +/// The walk STOPS at the first qualifying entry, so work is bounded by the distance to that entry, not +/// the retained length; an unchanged (entries reference, metadata revision) pair is a cached O(1) hit and +/// never rescans on streaming-only text changes that keep the same reference. +/// +/// +/// Usage attribution policy shared by the WinUI timeline and portable tests: only the GLOBAL latest +/// qualifying assistant may carry the full-retained usage summary. When the global id is unknown, the +/// window-local latest assistant is the fallback (previous behaviour). +/// +public static class ChatTimelineUsageAttribution +{ + public static string? Resolve(string? globalLatestEntryId, string? windowLatestEntryId) => + !string.IsNullOrEmpty(globalLatestEntryId) ? globalLatestEntryId : windowLatestEntryId; + + public static bool IsGlobalLatestAssistant(string? entryId, string? attributionEntryId) => + !string.IsNullOrEmpty(entryId) && + !string.IsNullOrEmpty(attributionEntryId) && + string.Equals(entryId, attributionEntryId, StringComparison.Ordinal); +} + +public sealed class ChatLatestUsageProjection +{ + /// Latest qualifying usage summary plus the work actually performed for this call. + public sealed record Result(string? Summary, string? EntryId, int ScannedEntries); + + // Topology key: identity + explicit usage revision + retained shape (count/first/last id). A + // text-only streaming edit preserves all of these, so it is an O(1) HIT and never rescans. + private sealed record State( + string? SessionId, + long UsageRevision, + int Count, + string? FirstId, + string? LastId, + Result Cached); + + private readonly Dictionary _states = new(StringComparer.Ordinal); + + /// + /// Resolves the latest qualifying usage summary for a thread over the FULL retained timeline. + /// fetches metadata by entry id (no full-dictionary copy). + /// + public Result Get( + string threadId, + string? sessionId, + long usageRevision, + ChatTimelineState timeline, + Func lookup) + { + ArgumentNullException.ThrowIfNull(timeline); + ArgumentNullException.ThrowIfNull(lookup); + + var entries = timeline.Entries; + var firstId = entries.Count > 0 ? entries[0].Id : null; + var lastId = entries.Count > 0 ? entries[^1].Id : null; + if (_states.TryGetValue(threadId, out var cached) && + cached.UsageRevision == usageRevision && + cached.Count == entries.Count && + string.Equals(cached.SessionId, sessionId, StringComparison.Ordinal) && + string.Equals(cached.FirstId, firstId, StringComparison.Ordinal) && + string.Equals(cached.LastId, lastId, StringComparison.Ordinal)) + { + return cached.Cached with { ScannedEntries = 0 }; // O(1): no rescan on text-only edits + } + var scanned = 0; + string? summary = null; + string? entryId = null; + for (var i = entries.Count - 1; i >= 0; i--) + { + scanned++; + var entry = entries[i]; + if (entry.Kind != ChatTimelineItemKind.Assistant) + continue; + var meta = lookup(entry.Id); + if (meta is null) + continue; + var text = ChatUsageFormatter.Format(meta); + if (string.IsNullOrWhiteSpace(text)) + continue; // a non-qualifying recent row must not hide a prior usable one + summary = text; + entryId = entry.Id; + break; + } + + var result = new Result(summary, entryId, scanned); + _states[threadId] = new State(sessionId, usageRevision, entries.Count, firstId, lastId, result); + return result; + } + + /// Drops the cached projection for a thread (reset / identity change). + public void Forget(string threadId) => _states.Remove(threadId); + + public void Clear() => _states.Clear(); +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatLoadOlderState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatLoadOlderState.cs new file mode 100644 index 0000000000..249627bf29 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatLoadOlderState.cs @@ -0,0 +1,14 @@ +namespace OpenClawTray.Chat; + +/// +/// Explicit load-earlier presentation state. Unknown/unavailable is deliberately distinct from an +/// authoritative exhaustion, and a transient failure is a retryable error rather than "complete". +/// +public enum ChatLoadOlderState +{ + Unavailable, + Available, + Loading, + Exhausted, + Error, +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs index ae66ec1aea..ceb48fe84e 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs @@ -114,6 +114,7 @@ internal void RememberLastSessionState(ChatProjectionContext context) timelineGenerations: new Dictionary(), historyRevisions: new Dictionary(), queuedMessages: new Dictionary>(), + acceptedSessionIds: new Dictionary(), status: ConnectionStatus.Disconnected, context)); var session = defaultThreadId is { Length: > 0 } @@ -248,6 +249,7 @@ internal ChatSnapshotProjectionInput CaptureProjectionInput( IReadOnlyDictionary timelineGenerations, IReadOnlyDictionary historyRevisions, IReadOnlyDictionary> queuedMessages, + IReadOnlyDictionary acceptedSessionIds, ConnectionStatus status, ChatProjectionContext context) => new( Sessions: _sessions.ToArray(), @@ -265,7 +267,8 @@ internal ChatSnapshotProjectionInput CaptureProjectionInput( RememberedDefaultThreadId: _lastChatState?.DefaultThreadId, RememberedThreadTitle: _lastChatState?.ThreadTitle, RememberedModel: _lastChatState?.Model, - RememberedModelProvider: _lastChatState?.ModelProvider); + RememberedModelProvider: _lastChatState?.ModelProvider, + AcceptedSessionIds: acceptedSessionIds); internal string ResolveTimelineKey( SessionInfo session, diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs index b31027769b..186dd0383a 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs @@ -20,7 +20,8 @@ internal sealed record ChatSnapshotProjectionInput( string? RememberedDefaultThreadId, string? RememberedThreadTitle, string? RememberedModel, - string? RememberedModelProvider); + string? RememberedModelProvider, + IReadOnlyDictionary? AcceptedSessionIds = null); internal static class ChatSnapshotProjector { @@ -91,7 +92,8 @@ internal static ChatDataSnapshot Project(ChatSnapshotProjectionInput input) CommandsSupported: input.CommandCatalog?.IsSupported ?? true, TimelineGenerations: input.TimelineGenerations, HistoryRevisions: input.HistoryRevisions, - QueuedMessagesByThread: input.QueuedMessages); + QueuedMessagesByThread: input.QueuedMessages, + AcceptedSessionIds: input.AcceptedSessionIds); } internal static string? ResolveDefaultThreadId(ChatSnapshotProjectionInput input) diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatTimelineDisplayWindow.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatTimelineDisplayWindow.cs new file mode 100644 index 0000000000..624712a3c2 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatTimelineDisplayWindow.cs @@ -0,0 +1,1169 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using OpenClaw.Chat; + +namespace OpenClawTray.Chat; + +/// +/// A bounded, MOVABLE display window over the full retained timeline. The complete logical/durable +/// history is never truncated or mutated: the window selects a fixed-size slice for the expensive +/// per-render projection. Older/newer navigation moves the window; it never grows to the full history. +/// +public sealed record ChatTimelineDisplayWindow( + IReadOnlyList VisibleEntries, + int HiddenEarlierCount, + int WindowSize, + int HiddenLaterCount = 0, + // True while a deep anchor source position is still being resolved off the UI path: the caller must NOT + // treat this window as resolved (do not feed it into the projection cache / stale HiddenEarlierCount). + bool Pending = false); + +/// +/// Portable navigation state reused by the ACTUAL root: per-thread older/newer anchors over the +/// retained timeline. Common-case resolution is O(1); the anchor is reconciled by stable entry identity +/// only when the FRONT of the retained list changed (server older-page prepend or reset), never on every +/// live append/render. Keeps a fixed window size and never projects the full retained history. +/// +public sealed class ChatTimelineNavigationState +{ + private sealed record Anchor(int EndExclusive, string FirstRetainedId, string? FirstVisibleId); + + private readonly Dictionary _anchors = new(StringComparer.Ordinal); + private readonly HashSet _pendingAnchors = new(StringComparer.Ordinal); + private long _revision; // bumped on every anchor mutation so a lease is revision-bound + // The IMMUTABLE retained root the pending anchor was last observed against: a lease captured from a + // DIFFERENT root (same count/front) must be rejected. + private readonly Dictionary> _pendingAnchorRoots = new(StringComparer.Ordinal); + // LOGICAL navigation identity (provider REFERENCE + accepted session UUID) the anchors for a thread belong + // to. Enforced BEFORE a window is computed so a UUID/provider transition on the SAME thread drops the scoped + // anchor state even when NO anchor flight is pending; a pre-reset RESOLVED anchor can never leak. + private readonly Dictionary _identities = new(StringComparer.Ordinal); + // Short state lock: the off-UI anchor resolver commits under THIS lock only (never a full scan under it). + private readonly object _sync = new(); + + private sealed record NavigationIdentity(object? Provider, string? AcceptedSessionUuid); + + /// Count of source-revision anchor searches (one per irreconcilable front change). + public int FindIdLookups { get; private set; } + + /// Resolves the current bounded window for a thread (tail when never navigated). + public ChatTimelineDisplayWindow Resolve(string? threadId, ChatTimelineState timeline, int windowSize) + { + ArgumentNullException.ThrowIfNull(timeline); + if (threadId is null) + return ChatTimelineDisplayWindowPolicy.Compute(timeline, windowSize); + Anchor anchor; + lock (_sync) + { + if (!_anchors.TryGetValue(threadId, out anchor!)) + return ChatTimelineDisplayWindowPolicy.Compute(timeline, windowSize); + } + + var entries = timeline.Entries; + if (entries.Count == 0) + { + // Empty / reset timeline: drop any stale anchor and return an empty window. Never index + // entries[0] here (an empty retained list has no front). + lock (_sync) + { + _anchors.Remove(threadId); + _pendingAnchors.Remove(threadId); + } + return ChatTimelineDisplayWindowPolicy.Compute(timeline, windowSize); + } + + var frontChanged = !string.Equals(entries[0].Id, anchor.FirstRetainedId, StringComparison.Ordinal); + var end = anchor.EndExclusive; + if (frontChanged) + { + if (entries.Count <= windowSize) + { + // The whole retained timeline fits: no anchor is needed. + lock (_sync) + { + _anchors.Remove(threadId); + _pendingAnchors.Remove(threadId); + } + return ChatTimelineDisplayWindowPolicy.Compute(timeline, windowSize); + } + if (anchor.FirstVisibleId is { } firstVisibleId) + { + // Reconcile by stable entry identity so a server older-page prepend does not jump the + // view. This is O(n) on the FRONT-change path only (rare: prepend/reset), not per render. + FindIdLookups++; + // BOUNDED synchronous first lookup. A NOT-found deep anchor is NOT absent: retain it and return + // an EXPLICIT PENDING window (never a tail mislabeled as resolved). The off-UI resolver completes + // the source-position index and the next render rebases, preserving the deep IDs. + var index = FindId(entries, firstVisibleId, ChatTimelineDisplayWindowPolicy.BoundedFindIdOperations); + lock (_sync) + { + // Re-verify the anchor under the short lock: a background commit or live action may have + // superseded it while the bounded scan ran outside the lock. + if (!_anchors.TryGetValue(threadId, out var current) || + !string.Equals(current.FirstVisibleId, firstVisibleId, StringComparison.Ordinal)) + { + return ChatTimelineDisplayWindowPolicy.ComputeAnchored(timeline, current?.EndExclusive ?? entries.Count, windowSize); + } + if (index < 0) + { + _pendingAnchors.Add(threadId); + _pendingAnchorRoots[threadId] = entries; // bind pending to THIS immutable root + return ChatTimelineDisplayWindowPolicy.Compute(timeline, windowSize) with { Pending = true }; + } + _pendingAnchors.Remove(threadId); + end = index + windowSize; + _anchors[threadId] = current with { EndExclusive = end, FirstRetainedId = entries[0].Id }; + } + } + } + + return ChatTimelineDisplayWindowPolicy.ComputeAnchored(timeline, end, windowSize); + } + + /// Moves the bounded window one page toward older entries. + public ChatTimelineDisplayWindow MoveOlder( + string threadId, ChatTimelineState timeline, int windowSize) + { + var current = Resolve(threadId, timeline, windowSize); + var newEnd = Math.Max(windowSize, current.HiddenEarlierCount); + Store(threadId, timeline, newEnd); + return Resolve(threadId, timeline, windowSize); + } + + /// Moves the bounded window one page back toward newer entries. + public ChatTimelineDisplayWindow MoveNewer( + string threadId, ChatTimelineState timeline, int windowSize) + { + var current = Resolve(threadId, timeline, windowSize); + var end = current.HiddenEarlierCount + current.VisibleEntries.Count; + var newEnd = Math.Min(timeline.Entries.Count, end + ChatTimelineDisplayWindowPolicy.NavigationPageSize); + if (newEnd >= timeline.Entries.Count) + { + lock (_sync) + { + _anchors.Remove(threadId); // back at the live tail + _pendingAnchors.Remove(threadId); // supersede any in-flight deep-anchor resolution + _pendingAnchorRoots.Remove(threadId); + _revision++; // a lease from before the live return is stale + } + return ChatTimelineDisplayWindowPolicy.Compute(timeline, windowSize); + } + Store(threadId, timeline, newEnd); + return Resolve(threadId, timeline, windowSize); + } + + /// Returns the thread to the live tail. + public ChatTimelineDisplayWindow ReturnToLive( + string threadId, ChatTimelineState timeline, int windowSize) + { + lock (_sync) + { + _anchors.Remove(threadId); + _pendingAnchors.Remove(threadId); // supersede any in-flight deep-anchor resolution + _pendingAnchorRoots.Remove(threadId); + _revision++; + } + return ChatTimelineDisplayWindowPolicy.Compute(timeline, windowSize); + } + + /// Drops navigation state for a thread (reset / identity change). + public void Forget(string threadId) + { + lock (_sync) + { + _anchors.Remove(threadId); + _pendingAnchors.Remove(threadId); + _pendingAnchorRoots.Remove(threadId); + _identities.Remove(threadId); // release the provider reference with the scoped navigation + _revision++; // a lease bound to this navigation is now stale + } + } + + /// + /// Enforces the LOGICAL navigation identity (provider REFERENCE + accepted session UUID) for a thread + /// BEFORE any window is computed. A transition on the SAME thread drops the scoped anchor state and + /// supersedes any pending resolution, so a pre-reset ANCHOR - whether or not a flight is pending - can never + /// leak into the new identity. The SAME identity PRESERVES the thread's navigation across ordinary older + /// pages. Returns true when a previously recorded identity was superseded. + /// + public bool EnsureNavigationIdentity(object? provider, string threadId, string? acceptedSessionUuid) + { + ArgumentNullException.ThrowIfNull(threadId); + lock (_sync) + { + if (_identities.TryGetValue(threadId, out var existing) && + ReferenceEquals(existing.Provider, provider) && + string.Equals(existing.AcceptedSessionUuid, acceptedSessionUuid, StringComparison.Ordinal)) + { + return false; // same logical identity: keep this thread's anchors + } + + var superseded = _identities.ContainsKey(threadId); + _identities[threadId] = new NavigationIdentity(provider, acceptedSessionUuid); + if (superseded) + { + _anchors.Remove(threadId); + _pendingAnchors.Remove(threadId); + _pendingAnchorRoots.Remove(threadId); + _revision++; // a lease bound to the previous identity is now stale + } + return superseded; + } + } + + /// True while a deep anchor source position is still being resolved. + public bool HasPendingAnchor(string threadId) => _pendingAnchors.Contains(threadId); + + /// + /// Captures the EXACT pending-anchor identity the off-UI resolver must find. The lease is compare-and-committed + /// via TryCommitAnchorIndex, so a stale completion can never reinsert an anchor or revert a live view. + /// + public AnchorResolutionLease? CapturePendingAnchor( + string threadId, ChatTimelineState timeline, int windowSize, string? acceptedSessionUuid = null) + { + ArgumentNullException.ThrowIfNull(timeline); + lock (_sync) + { + if (!_pendingAnchors.Contains(threadId) || + !_anchors.TryGetValue(threadId, out var anchor) || + anchor.FirstVisibleId is not { } firstVisibleId) + { + return null; + } + return new AnchorResolutionLease( + threadId, windowSize, firstVisibleId, anchor.FirstRetainedId, timeline.Entries.Count, + timeline.Entries, _revision, acceptedSessionUuid); + } + } + + /// + /// COMPLETE source-position index for a captured anchor. PURE: performs the unbounded linear search OFF the UI + /// path and NEVER touches state, so it is safe on a background thread. The caller decides "truly absent" only + /// from THIS complete index (-1); a partial scan is never treated as absence. + /// + public static int FindAnchorIndex(IReadOnlyList entries, AnchorResolutionLease lease) + { + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(lease); + for (var i = 0; i < entries.Count; i++) + { + if (string.Equals(entries[i].Id, lease.FirstVisibleId, StringComparison.Ordinal)) + return i; + } + return -1; + } + + /// + /// Commits a completed index ONLY while the captured anchor is STILL the pending one for the thread. A + /// superseded scope (ReturnToLive/Forget/Clear/reset/UUID/provider dispose/new front) returns false, so the + /// stale completion is dropped instead of reverting the live view. index < 0 forgets (truly absent). + /// + public bool TryCommitAnchorIndex(AnchorResolutionLease lease, ChatTimelineState timeline, int index) + { + ArgumentNullException.ThrowIfNull(lease); + ArgumentNullException.ThrowIfNull(timeline); + lock (_sync) + { + if (!_pendingAnchors.Contains(lease.ThreadId) || + !_anchors.TryGetValue(lease.ThreadId, out var anchor) || + !string.Equals(anchor.FirstVisibleId, lease.FirstVisibleId, StringComparison.Ordinal)) + { + return false; // superseded: never reinsert/revert + } + // IMMUTABLE-root + revision guard: a same-count/same-front but DIFFERENT root (or newer revision) + // must reject a stale capture even when front/count still match. + if (!ReferenceEquals(timeline.Entries, lease.Root) || _revision != lease.NavigationRevision) + return false; + if (!_pendingAnchorRoots.TryGetValue(lease.ThreadId, out var pendingRoot) || + !ReferenceEquals(pendingRoot, lease.Root)) + { + return false; // the app has moved to a different immutable root + } + _pendingAnchors.Remove(lease.ThreadId); + _pendingAnchorRoots.Remove(lease.ThreadId); + _revision++; + if (index < 0) + { + _anchors.Remove(lease.ThreadId); // truly absent after the COMPLETE index + return true; + } + var entries = timeline.Entries; + _anchors[lease.ThreadId] = anchor with + { + EndExclusive = index + lease.WindowSize, + FirstRetainedId = entries.Count == 0 ? string.Empty : entries[0].Id, + }; + return true; + } + } + + /// + /// COMPLETE (unbounded) resolution of a pending deep anchor, run OFF the UI path by the root lifecycle. + /// Applies the found index and clears pending; genuinely absent after the COMPLETE search forgets the anchor + /// (never a scan limit treated as absence). + /// + public bool CompleteAnchorResolution(string threadId, ChatTimelineState timeline, int windowSize) + { + ArgumentNullException.ThrowIfNull(timeline); + if (!_anchors.TryGetValue(threadId, out var anchor) || anchor.FirstVisibleId is not { } firstVisibleId) + { + _pendingAnchors.Remove(threadId); + return false; + } + var entries = timeline.Entries; + var index = FindId(entries, firstVisibleId, entries.Count); // COMPLETE search + _pendingAnchors.Remove(threadId); + if (index < 0) + { + _anchors.Remove(threadId); // genuinely absent after the complete index + return false; + } + _anchors[threadId] = anchor with + { + EndExclusive = index + windowSize, + FirstRetainedId = entries.Count == 0 ? string.Empty : entries[0].Id, + }; + return true; + } + + public void Clear() + { + lock (_sync) + { + _anchors.Clear(); + _pendingAnchors.Clear(); + _pendingAnchorRoots.Clear(); + _identities.Clear(); + _revision++; // a lease bound to this navigation is now stale + } + } + + private void Store(string threadId, ChatTimelineState timeline, int endExclusive) + { + var entries = timeline.Entries; + if (entries.Count == 0) + { + _anchors.Remove(threadId); + return; + } + var clamped = Math.Clamp(endExclusive, 0, entries.Count); + var start = Math.Max(0, clamped - ChatTimelineDisplayWindowPolicy.DefaultWindowSize); + var firstVisibleId = clamped > start ? entries[start].Id : null; + lock (_sync) + { + _anchors[threadId] = new Anchor(clamped, entries[0].Id, firstVisibleId); + _pendingAnchors.Remove(threadId); + _pendingAnchorRoots.Remove(threadId); + _revision++; + } + } + + private static int FindId( + System.Collections.Immutable.ImmutableList entries, string id, int maxOperations) + { + var bound = Math.Min(entries.Count, maxOperations); + for (var i = 0; i < bound; i++) + { + if (string.Equals(entries[i].Id, id, StringComparison.Ordinal)) + return i; + } + return -1; + } +} + +/// Bounded display-window policy: fixed window size and one-page navigation step. +public static class ChatTimelineDisplayWindowPolicy +{ + public const int DefaultWindowSize = 400; + public const int NavigationPageSize = 200; + + /// Hard bound on the FIRST anchor-reconcile search (front-change path). + public const int BoundedFindIdOperations = 4096; + + /// Window anchored at the end of the retained timeline (the live tail). + public static ChatTimelineDisplayWindow Compute(ChatTimelineState timeline, int windowSize) => + ComputeAnchored(timeline, timeline.Entries.Count, windowSize); + + /// + /// Window of at most entries ending at + /// (chronological order preserved). O(windowSize), never O(retained). + /// + public static ChatTimelineDisplayWindow ComputeAnchored( + ChatTimelineState timeline, int endExclusive, int windowSize) + { + ArgumentNullException.ThrowIfNull(timeline); + if (windowSize < 1) + windowSize = 1; + var all = timeline.Entries; + var count = all.Count; + var end = Math.Clamp(endExclusive, 0, count); + var start = Math.Max(0, end - windowSize); + var visible = new List(end - start); + for (var i = start; i < end; i++) + visible.Add(all[i]); + return new ChatTimelineDisplayWindow(visible, start, windowSize, count - end); + } + + /// Moves the bounded window one page toward older entries (size stays bounded). + public static ChatTimelineDisplayWindow MoveOlder( + ChatTimelineDisplayWindow current, ChatTimelineState timeline) + { + ArgumentNullException.ThrowIfNull(current); + var end = current.HiddenEarlierCount + current.VisibleEntries.Count; + var oldestEnd = Math.Min(current.WindowSize, timeline.Entries.Count); + return ComputeAnchored(timeline, Math.Max(oldestEnd, end - NavigationPageSize), current.WindowSize); + } + + /// Moves the bounded window one page back toward newer entries. + public static ChatTimelineDisplayWindow MoveNewer( + ChatTimelineDisplayWindow current, ChatTimelineState timeline) + { + ArgumentNullException.ThrowIfNull(current); + var end = current.HiddenEarlierCount + current.VisibleEntries.Count; + return ComputeAnchored( + timeline, + Math.Min(timeline.Entries.Count, end + NavigationPageSize), + current.WindowSize); + } +} + +/// +/// Fixed-capacity projection cache keyed by a STABLE WEAK IDENTITY of the IMMUTABLE Entries root (not +/// count/NextId, which collide on immutable SetItem edits such as streaming text / tool / permission +/// updates), plus the actual session UUID, generation and window size. The identity is a plain token +/// obtained from a ConditionalWeakTable keyed by the Entries root: the token NEVER references the root, +/// so the cache cannot retain an abandoned full-history root merely because it holds a bounded NUMBER of +/// visible rows. Distinct roots (including a new root from an immutable SetItem edit) get distinct tokens, +/// preserving SetItem invalidation and reset/UUID/anchor isolation. Memory is bounded by entry slots AND +/// by cached rows. Keying uses reference identity only - no per-render content hashing or full scan. +/// +public sealed class ChatTimelineProjectionCache +{ + /// + /// Identity-only token stored as the VALUE of the weak root table. It is intentionally empty: it must + /// never reference the timeline root, so holding it can never keep an abandoned root alive. + /// + private sealed class RootIdentityToken + { + } + + private readonly int _capacity; + private readonly int _maxRows; + // Weak root -> token map: the root is held WEAKLY here, so an abandoned root is collectible while the + // cache remains alive. The dictionary/LRU below retain only the token, never the root. + private readonly ConditionalWeakTable, RootIdentityToken> _rootIdentities = new(); + private readonly Dictionary< + (string? SessionId, long Generation, int WindowSize, int Anchor, RootIdentityToken Root), + ChatTimelineDisplayWindow> _windows = new(); + private readonly Queue< + (string? SessionId, long Generation, int WindowSize, int Anchor, RootIdentityToken Root)> _order = new(); + private int _cachedRows; + + public ChatTimelineProjectionCache(int capacity = 8, int maxRows = 4_000) + { + _capacity = Math.Max(1, capacity); + _maxRows = Math.Max(1, maxRows); + } + + /// Entries visited by the most recent MISS (0 on a hit) - measured projection work. + public int LastMissVisitedEntries { get; private set; } + + /// Number of computed (miss) projections so far. + public int MissCount { get; private set; } + + /// Rows currently held by the cache (bounded by maxRows). + public int CachedRows => _cachedRows; + + public ChatTimelineDisplayWindow Get( + string? sessionId, ChatTimelineState timeline, long generation, int windowSize, + int? endExclusive = null) + { + ArgumentNullException.ThrowIfNull(timeline); + var anchor = endExclusive ?? timeline.Entries.Count; + var root = _rootIdentities.GetValue(timeline.Entries, static _ => new RootIdentityToken()); + var key = (sessionId, generation, windowSize, anchor, root); + if (_windows.TryGetValue(key, out var cached)) + { + LastMissVisitedEntries = 0; + return cached; + } + + var computed = ChatTimelineDisplayWindowPolicy.ComputeAnchored(timeline, anchor, windowSize); + // Only cache windows that FIT the row bound; an oversized window is returned but never cached, + // so CachedRows can never exceed maxRows for any accepted configuration. + if (computed.VisibleEntries.Count <= _maxRows) + { + _windows[key] = computed; + _order.Enqueue(key); + _cachedRows += computed.VisibleEntries.Count; + while (_order.Count > _capacity || _cachedRows > _maxRows) + { + var evicted = _order.Dequeue(); + if (_windows.Remove(evicted, out var removed)) + _cachedRows -= removed.VisibleEntries.Count; + } + } + LastMissVisitedEntries = computed.VisibleEntries.Count; + MissCount++; + return computed; + } +} + + +/// +/// Scoped holder for the last STABLE bounded window: a held window is valid ONLY for the SAME logical session +/// identity (thread + accepted UUID + provider). A scope change discards it, so an offset from one timeline root +/// can never be applied to a DIFFERENT root. +/// +/// +/// STRUCTURED logical identity of a held window. Reference identity is enforced explicitly for the provider: +/// a provider that overrides Equals to report value-equality can NEVER be confused with a different object. +/// +public readonly struct ChatTimelineWindowScopeKey : IEquatable +{ + public ChatTimelineWindowScopeKey(object? provider, string? threadId, string? acceptedSessionUuid) + { + Provider = provider; + ThreadId = threadId; + AcceptedSessionUuid = acceptedSessionUuid; + } + + public object? Provider { get; } + public string? ThreadId { get; } + public string? AcceptedSessionUuid { get; } + + public bool Equals(ChatTimelineWindowScopeKey other) => + ReferenceEquals(Provider, other.Provider) && + string.Equals(ThreadId, other.ThreadId, StringComparison.Ordinal) && + string.Equals(AcceptedSessionUuid, other.AcceptedSessionUuid, StringComparison.Ordinal); + + public override bool Equals(object? obj) => obj is ChatTimelineWindowScopeKey other && Equals(other); + + public override int GetHashCode() => + (Provider is null ? 0 : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(Provider)); +} + +public sealed class ChatTimelineStableWindowScope +{ + private readonly object _sync = new(); + private (ChatTimelineWindowScopeKey Key, ChatTimelineDisplayWindow Window)? _held; + + public ChatTimelineDisplayWindow? Get(ChatTimelineWindowScopeKey key) + { + lock (_sync) + return _held is { } held && held.Key.Equals(key) ? held.Window : null; + } + + public void Set(ChatTimelineWindowScopeKey key, ChatTimelineDisplayWindow window) + { + lock (_sync) + _held = (key, window); + } + + public void Clear() + { + lock (_sync) + _held = null; + } +} + +/// +/// ACTUAL pending presentation policy for a deep-anchor resolution in flight: the caller must NEVER render the +/// new tail / stale offset as resolved. Hold the last STABLE bounded window when one is available (same bounded +/// IDs, so expansion/context are preserved), otherwise present an explicit LOADING window (no rows, Pending). +/// +public static class ChatTimelinePendingPresentationPolicy +{ + public static ChatTimelineDisplayWindow Resolve( + ChatTimelineDisplayWindow? lastStable, ChatTimelineDisplayWindow navigationWindow) + { + ArgumentNullException.ThrowIfNull(navigationWindow); + if (!navigationWindow.Pending) + return navigationWindow; + if (lastStable is not null && !lastStable.Pending) + return lastStable with { Pending = true }; // hold last stable bounded IDs, marked loading + return navigationWindow with + { + VisibleEntries = Array.Empty(), + HiddenEarlierCount = 0, + HiddenLaterCount = 0, + Pending = true, + }; + } +} + +/// +/// Identity of a pending deep-anchor resolution: the exact (thread, window, first-visible id, retained front) +/// the off-UI resolver must find. Compare-and-committed so a stale completion cannot reinsert an anchor or +/// revert the live view. +/// +public sealed record AnchorResolutionLease( + string ThreadId, + int WindowSize, + string FirstVisibleId, + string FirstRetainedId, + int RetainedCount, + // IMMUTABLE-root identity + navigation revision: a lease is bound to the EXACT retained root and revision it + // was captured from, so a same-count/same-front but DIFFERENT root (shifted positions) can never be reused or + // apply a stale index. + IReadOnlyList Root, + long NavigationRevision, + // AUTHORITATIVE accepted session UUID this capture belongs to: a UUID identity transition invalidates the + // scoped anchor state even when the root/front/count/row ids are identical. + string? AcceptedSessionUuid); + +/// +/// Lifecycle owner of the DEEP-ANCHOR source-position resolution for one root: one owned off-UI flight per +/// active lease, cancelled/superseded on scope change, and a completion that is committed ONLY while its lease +/// is still current. The COMPLETE index runs OFF the UI path (pure search); the commit happens under the +/// navigation state's short lock. A partial scan is never treated as absence. +/// +public sealed class ChatTimelineAnchorResolutionCoordinator : IDisposable +{ + private readonly ChatTimelineNavigationState _navigation; + private readonly object _sync = new(); + private AnchorResolutionLease? _active; + private Flight? _flight; // the OWNED flight (identity), never a bare bool + private Request? _latest; // latest capture requested this render (for autonomous drain) + private Action? _lastOnResolved; // reused so a drained new capture still re-renders + private bool _disposed; + + private sealed record Request(AnchorResolutionLease Lease, ChatTimelineState Timeline, Action OnResolved); + + private sealed class Flight + { + public Flight(Request request) => Request = request; + public Request Request { get; } + public CancellationTokenSource Cancellation { get; } = new(); + } + + public ChatTimelineAnchorResolutionCoordinator(ChatTimelineNavigationState navigation) => + _navigation = navigation ?? throw new ArgumentNullException(nameof(navigation)); + + /// + /// Registers this render's scope and returns the pending resolution lease (null when there is none). A + /// DIFFERENT pending scope supersedes the previous one so its completion is dropped. + /// + public AnchorResolutionLease? Begin( + string threadId, ChatTimelineState timeline, int windowSize, string? acceptedSessionUuid = null) + { + lock (_sync) + { + if (_disposed) + return null; + // UUID IDENTITY TRANSITION: the same thread with a NEW accepted UUID invalidates the scoped anchor + // state, so a pre-reset lease can never be reused even when root/front/count/row ids match. + if (_active is { } previous && + string.Equals(previous.ThreadId, threadId, StringComparison.Ordinal) && + !string.Equals(previous.AcceptedSessionUuid, acceptedSessionUuid, StringComparison.Ordinal)) + { + _latest = null; + _navigation.Forget(threadId); + _flight?.Cancellation.Cancel(); + } + var lease = _navigation.CapturePendingAnchor(threadId, timeline, windowSize, acceptedSessionUuid); + if (lease is null) + { + _active = null; + _latest = null; + _lastOnResolved = null; + _flight?.Cancellation.Cancel(); // no new capture: stop the obsolete producer + return null; + } + if (_active is { } active && + string.Equals(active.ThreadId, lease.ThreadId, StringComparison.Ordinal) && + string.Equals(active.FirstVisibleId, lease.FirstVisibleId, StringComparison.Ordinal) && + ReferenceEquals(active.Root, lease.Root) && // SAME immutable root + active.NavigationRevision == lease.NavigationRevision) + { + return active; + } + _active = lease; + // A CHANGED capture (Begin-only, no TryResolvePending) must stop the obsolete producer PROMPTLY: its + // completion would be dropped anyway, so cancel the in-flight full scan at its next batch boundary + // instead of letting it run to completion. The drain resumes the latest capture when a callback is + // registered for the current scope. + if (_flight is { } obsolete && !SameLease(obsolete.Request.Lease, lease)) + obsolete.Cancellation.Cancel(); + // Record the new capture ONLY when a callback is registered for the current scope (a stale callback + // from a previous provider must never be reused). + _latest = _lastOnResolved is { } registered ? new Request(lease, timeline, registered) : null; + return lease; + } + } + + /// + /// Starts the ONE owned off-UI completion for the active lease (no spin/retry). Returns false when nothing + /// was started (no pending anchor, a flight is already owned, or superseded). onResolved fires only when the + /// commit was applied for the STILL-current lease. + /// + public bool TryResolvePending(ChatTimelineState timeline, Action onResolved) + { + ArgumentNullException.ThrowIfNull(timeline); + ArgumentNullException.ThrowIfNull(onResolved); + AnchorResolutionLease active; + Flight flight; + lock (_sync) + { + if (_disposed || _active is not { } current) + return false; + active = current; + _lastOnResolved = onResolved; + _latest = new Request(active, timeline, onResolved); // remember for autonomous drain + if (_flight is { } owned) + { + if (SameLease(owned.Request.Lease, active)) + return true; // same capture already in flight + owned.Cancellation.Cancel(); // stop the obsolete producer promptly + return true; // ONE physical producer; the drain resumes latest + } + flight = new Flight(_latest!); + _flight = flight; + } + _ = Task.Run(() => RunFlight(flight), CancellationToken.None); + return true; + } + + /// Deterministic test instrumentation: counts PHYSICAL producer tasks actually started. + internal int PhysicalProducersStarted; + + /// Deterministic test instrumentation: maximum concurrently running producer tasks. + internal int MaxConcurrentProducers; + + private int _concurrentProducers; + + /// Deterministic test gate invoked at the very start of a flight, BEFORE the scan. + internal Action? BeforeScanForTests { get; set; } + + /// Resolves when a flight has passed its pre-scan gate (test synchronization, not a sleep). + internal event Action? FlightEnteredScanForTests; + + /// Fired after a physical producer has fully drained (test synchronization, not a sleep). + internal event Action? FlightCompletedForTests; + + private void RunFlight(Flight flight) + { + try + { + Interlocked.Increment(ref PhysicalProducersStarted); + var concurrent = Interlocked.Increment(ref _concurrentProducers); + for (var current = Volatile.Read(ref MaxConcurrentProducers); concurrent > current; + current = Volatile.Read(ref MaxConcurrentProducers)) + { + Interlocked.CompareExchange(ref MaxConcurrentProducers, concurrent, current); + } + BeforeScanForTests?.Invoke(); + FlightEnteredScanForTests?.Invoke(); + // COMPLETE search OFF the UI path, PER-BATCH cancellable; no shared UI lock held. + var index = FindIndex( + flight.Request.Timeline.Entries, flight.Request.Lease.FirstVisibleId, + flight.Cancellation.Token); + if (flight.Cancellation.IsCancellationRequested) + return; + bool committed; + lock (_sync) + { + if (_disposed || _active is not { } current || + !SameLease(current, flight.Request.Lease) || + !ReferenceEquals(_flight, flight)) + { + return; // superseded: drop the completion (never publish obsolete work) + } + committed = _navigation.TryCommitAnchorIndex( + flight.Request.Lease, flight.Request.Timeline, index); + } + if (committed && !flight.Cancellation.IsCancellationRequested) + { + // FENCE the wakeup UNDER the ownership lock: there is no gap between the supersession check and + // the invoke, so a Reset/Dispose/new scope cannot let obsolete work re-render. + lock (_sync) + { + if (!_disposed && ReferenceEquals(_active, flight.Request.Lease) && + ReferenceEquals(_flight, flight)) + { + flight.Request.OnResolved(); // re-render with the deep IDs preserved + } + } + } + } + finally + { + Interlocked.Decrement(ref _concurrentProducers); + DrainOwned(flight); + FlightCompletedForTests?.Invoke(); // deterministic test signal: this physical producer fully drained + } + } + + /// + /// Releases ownership ONLY if this flight still owns it, then AUTONOMOUSLY starts the latest requested + /// capture when it differs (drain resume) - no extra UI query needed. + /// + private void DrainOwned(Flight flight) + { + Flight? nextFlight = null; + lock (_sync) + { + if (ReferenceEquals(_flight, flight)) + _flight = null; + if (!_disposed && _flight is null && _latest is { } latest && + !SameLease(latest.Lease, flight.Request.Lease)) + { + nextFlight = new Flight(latest); + _flight = nextFlight; + } + } + flight.Cancellation.Dispose(); // the finished flight releases its CTS + if (nextFlight is not null) + _ = Task.Run(() => RunFlight(nextFlight), CancellationToken.None); // exact instance + } + + private static bool SameLease(AnchorResolutionLease a, AnchorResolutionLease b) => + string.Equals(a.ThreadId, b.ThreadId, StringComparison.Ordinal) && + string.Equals(a.AcceptedSessionUuid, b.AcceptedSessionUuid, StringComparison.Ordinal) && // UUID identity + a.WindowSize == b.WindowSize && // full logical capture: a custom window must not reuse a lease + string.Equals(a.FirstVisibleId, b.FirstVisibleId, StringComparison.Ordinal) && + ReferenceEquals(a.Root, b.Root) && + a.NavigationRevision == b.NavigationRevision; + + private static int FindIndex(IReadOnlyList entries, string id, CancellationToken token) + { + for (var i = 0; i < entries.Count; i++) + { + if ((i & 4095) == 0 && token.IsCancellationRequested) + return -1; // per-batch cancellation; a cancelled partial is never treated as absence + if (string.Equals(entries[i].Id, id, StringComparison.Ordinal)) + return i; + } + return -1; + } + + /// Cancels in-flight work but keeps this coordinator USABLE (provider change, not unmount). + public void Reset() + { + Flight? owned; + lock (_sync) + { + _active = null; + _latest = null; + _lastOnResolved = null; // a stale callback must not be reused by the next provider + owned = _flight; // KEEP ownership until the flight physically drains + } + CancelSafely(owned?.Cancellation); // ownership-safe: never throws on a concurrently disposed CTS + } + + private static void CancelSafely(CancellationTokenSource? cancellation) + { + if (cancellation is null) + return; + try + { + cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // The owning flight already drained and disposed it: nothing left to cancel. + } + } + + public void Dispose() + { + Flight? owned; + lock (_sync) + { + _disposed = true; + _active = null; + _latest = null; + _lastOnResolved = null; + owned = _flight; + _flight = null; + } + CancelSafely(owned?.Cancellation); // cancel promptly on dispose + } +} + +/// +/// Retained logical-history facts derived from the FULL accepted timeline (never the visible window). Fields +/// are NULL while PENDING so the render never invents a false / new-session flag from a partial scan. +/// +public readonly record struct ChatTimelineRetainedFacts( + bool? HasAnyUser, + bool? CurrentTurnHasAssistant, + bool Pending); + +/// +/// Producer of retained facts with a BOUNDED synchronous fast path (never O(full retained) on the UI render), +/// a weak-root-keyed cache, and an off-UI completion under a cancellation guard. Same discipline as the +/// projection/activity caches: the weak-root table never keeps an abandoned root alive. +/// +public sealed class ChatTimelineRetainedFactsProducer +{ + private sealed class RootToken + { + } + + /// Hard bound on synchronous retained-scan work per render. + public const int BoundedResumeOperations = 256; + + private readonly int _capacity; + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _tokens = new(); + private readonly Dictionary<(RootToken Root, long Generation), ChatTimelineRetainedFacts> _facts = new(); + private readonly Queue<(RootToken Root, long Generation)> _order = new(); + private readonly object _sync = new(); + + public ChatTimelineRetainedFactsProducer(int capacity = 32) => _capacity = Math.Max(1, capacity); + + /// Bounded fast path for the UI render; Pending=true when the bound was hit. + public ChatTimelineRetainedFacts GetBounded( + IReadOnlyList entries, long generation, CancellationToken cancellationToken = default) => + Resolve(entries, generation, BoundedResumeOperations, cancellationToken: cancellationToken); + + /// Unbounded (deterministic) facts; tests and the async completion use this. + public ChatTimelineRetainedFacts Get(IReadOnlyList entries, long generation) => + Resolve(entries, generation, int.MaxValue); + + /// + /// Schedules the off-UI completion. Returns FALSE when the facts are ALREADY faithful (nothing to do), so + /// the caller reads the bounded path again instead of waiting for a wakeup that would never come. + /// + public bool TryScheduleCompletion( + IReadOnlyList entries, long generation, CancellationToken cancellationToken, + Action onResolved) + { + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(onResolved); + var token = _tokens.GetValue(entries, static _ => new RootToken()); + lock (_sync) + { + if (_facts.TryGetValue((token, generation), out var cached) && !cached.Pending) + return false; // already faithful: the caller reads the bounded path instead of waiting + } + _ = Task.Run(() => + { + if (cancellationToken.IsCancellationRequested) + return; + var resolved = Resolve(entries, generation, int.MaxValue, allowCache: false, cancellationToken); + if (resolved.Pending || cancellationToken.IsCancellationRequested) + return; + onResolved(); + }, CancellationToken.None); + return true; + } + + private ChatTimelineRetainedFacts Resolve( + IReadOnlyList entries, long generation, int maxOperations, bool allowCache = true, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entries); + var token = _tokens.GetValue(entries, static _ => new RootToken()); + if (allowCache) + { + lock (_sync) + { + if (_facts.TryGetValue((token, generation), out var cached) && !cached.Pending) + return cached; + } + } + var computed = Compute(entries, maxOperations, cancellationToken); + if (!computed.Pending) + Publish(token, generation, computed); + return computed; + } + + private void Publish(RootToken token, long generation, ChatTimelineRetainedFacts computed) + { + lock (_sync) + { + var key = (token, generation); + if (_facts.TryGetValue(key, out var existing) && !existing.Pending) + return; // never regress a faithful entry + _facts[key] = computed; + _order.Enqueue(key); + while (_order.Count > _capacity) + _facts.Remove(_order.Dequeue()); + } + } + + private static ChatTimelineRetainedFacts Compute( + IReadOnlyList entries, int maxOperations, CancellationToken cancellationToken) + { + var operations = 0; + var pending = false; + bool Budget() + { + if (++operations > maxOperations) + { + pending = true; + return false; + } + if ((operations & 63) == 0 && cancellationToken.IsCancellationRequested) + { + pending = true; + return false; + } + return true; + } + + bool? hasAnyUser = false; + for (var i = 0; i < entries.Count; i++) + { + if (!Budget()) + { + hasAnyUser = null; // unknown while pending: never invent "no user" + break; + } + if (entries[i].Kind == ChatTimelineItemKind.User) + { + hasAnyUser = true; + break; + } + } + + bool? currentTurnHasAssistant = false; + for (var i = entries.Count - 1; i >= 0; i--) + { + if (!Budget()) + { + currentTurnHasAssistant = null; + break; + } + var kind = entries[i].Kind; + if (kind == ChatTimelineItemKind.User) + break; + if (kind == ChatTimelineItemKind.Assistant) + { + currentTurnHasAssistant = true; + break; + } + } + + return new ChatTimelineRetainedFacts(hasAnyUser, currentTurnHasAssistant, pending); + } +} + +/// +/// Lifecycle owner of retained-facts work for ONE chat root. Begin(...) on EVERY render cancels/supersedes a +/// DIFFERENT scope and REUSES the lease for an identical scope; exactly ONE completion flight is owned per +/// lease, cancellation is per-batch, and the callback commits under the same fence. Dispose() cancels. +/// +public sealed class ChatTimelineRetainedFactsCoordinator : IDisposable +{ + public sealed class Lease + { + internal Lease(string? sessionId, IReadOnlyList root, long generation, long version) + { + SessionId = sessionId; + Root = root; + Generation = generation; + Version = version; + } + + public string? SessionId { get; } + public IReadOnlyList Root { get; } + public long Generation { get; } + internal long Version { get; } + internal CancellationTokenSource Cancellation { get; } = new(); + } + + private readonly ChatTimelineRetainedFactsProducer _producer; + private readonly object _sync = new(); + private Lease? _active; + private Lease? _flight; + private long _renderVersion; + private bool _disposed; + + public ChatTimelineRetainedFactsCoordinator(ChatTimelineRetainedFactsProducer producer) => + _producer = producer ?? throw new ArgumentNullException(nameof(producer)); + + public Lease Begin(string? sessionId, IReadOnlyList root, long generation) + { + lock (_sync) + { + if (_active is { } current && + string.Equals(current.SessionId, sessionId, StringComparison.Ordinal) && + ReferenceEquals(current.Root, root) && + current.Generation == generation) + { + return current; // identical scope: do not cancel/restart in-flight work + } + _active?.Cancellation.Cancel(); + var lease = new Lease(sessionId, root, generation, ++_renderVersion); + if (_disposed) + { + lease.Cancellation.Cancel(); + return lease; + } + _active = lease; + return lease; + } + } + + /// Bounded facts for the lease, or NULL while the async completion is pending. + public ChatTimelineRetainedFacts? TryBounded(Lease lease) + { + var facts = _producer.GetBounded(lease.Root, lease.Generation, lease.Cancellation.Token); + return facts.Pending ? null : facts; + } + + /// + /// Owns exactly ONE flight per lease; returns FALSE when the facts are already faithful (caller rereads). + /// + public bool RequestCompletion(Lease lease, Action onResolved) + { + ArgumentNullException.ThrowIfNull(onResolved); + lock (_sync) + { + if (_disposed || !ReferenceEquals(_active, lease)) + return false; + if (ReferenceEquals(_flight, lease)) + return true; + _flight = lease; + } + var scheduled = _producer.TryScheduleCompletion( + lease.Root, lease.Generation, lease.Cancellation.Token, + () => + { + lock (_sync) + { + if (!ReferenceEquals(_flight, lease)) + return; + _flight = null; + if (_disposed || !ReferenceEquals(_active, lease)) + return; + onResolved(); // commit under the same fence + } + }); + if (!scheduled) + { + lock (_sync) + { + if (ReferenceEquals(_flight, lease)) + _flight = null; + } + } + return scheduled; + } + + /// Cancels in-flight work but keeps this coordinator USABLE (provider change, not unmount). + public void Reset() + { + lock (_sync) + { + _active?.Cancellation.Cancel(); + _active = null; + _flight = null; + } + } + + public void Dispose() + { + lock (_sync) + { + _disposed = true; + _active?.Cancellation.Cancel(); + _active = null; + _flight = null; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatTimelinePresentationContext.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatTimelinePresentationContext.cs index 311483c920..25f77ee38b 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatTimelinePresentationContext.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatTimelinePresentationContext.cs @@ -3,6 +3,7 @@ namespace OpenClawTray.Chat; + /// /// Presentation inputs shared by the Reactor chat timeline and its focused card renderers. /// @@ -27,4 +28,14 @@ public sealed record ChatTimelinePresentationContext( Func>? ResolveAssistantMediaAsync = null, IReadOnlyList? QueuedMessages = null, - Action? OnCancelQueuedMessage = null); + Action? OnCancelQueuedMessage = null, + ChatLoadOlderState LoadOlderState = ChatLoadOlderState.Unavailable, + Action? OnRetryLoadOlder = null, + bool CanShowNewerHistory = false, + Action? OnShowNewerHistory = null, + string? LatestUsageEntryId = null, + ChatToolActivityPresentation.ChatToolActivityWindowContext? ToolActivityWindow = null, + bool ToolActivityPending = false, + // True while a DEEP anchor resolution is in flight: the consumer shows a loading/holding state and must not + // present the visible slice as a resolved window. + bool WindowPending = false); diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatToolActivityPresentation.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatToolActivityPresentation.cs index e36b4bfa5f..6a8669961a 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatToolActivityPresentation.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatToolActivityPresentation.cs @@ -33,9 +33,18 @@ public sealed record ChatToolActivityRow( string Key, ChatTimelineItem? Entry, IReadOnlyList Tools, - ChatToolActivitySummary? Summary) + ChatToolActivitySummary? Summary, + int HiddenEarlierToolCount = 0, + bool ContinuesAfterWindow = false, + // True while the source-fact context for this window is PROVISIONAL (async completion pending). The + // consumer must hold any expansion/continuation state rather than treating the row as a fresh group. + bool Pending = false) { - public bool IsActivityGroup => Tools.Count >= 2; + // A span is a logical activity GROUP when it has at least two visible tools OR it continues a group + // whose earlier tools are hidden BEFORE the bounded window - so a moved/clipped window never demotes a + // mid-group tool to a standalone row. + public bool IsActivityGroup => + Tools.Count >= 2 || HiddenEarlierToolCount > 0 || ContinuesAfterWindow; } /// @@ -52,8 +61,14 @@ public static IReadOnlyList Project( IReadOnlyList entries, string? sessionId, long timelineGeneration, - bool showToolCalls = true) + bool showToolCalls = true, + ChatToolActivityWindowContext? windowContext = null, + bool pending = false) { + var context = windowContext ?? ChatToolActivityWindowContext.None; + // PENDING: no faithful source-fact context yet. Rows are marked Pending so the ACTUAL consumer shows a + // clear loading state and never treats the local (visible) boundary as a fabricated logical identity. + var isPending = pending || context.Provisional; var rows = new List(entries.Count); for (var index = 0; index < entries.Count;) { @@ -88,18 +103,32 @@ public static IReadOnlyList Project( } var count = end - index; - if (count == 1) + // SOURCE-FAITHFUL identity: a span clipped at the FRONT of the window is a CONTINUATION of the + // logical group that began earlier in the full accepted history - keep its original key and mark + // it a group even when only one tool remains visible. + var isLeadingSpan = index == 0 && context.LeadingGroupFirstToolId is not null; + // A span whose logical group CONTINUES past the window end (hidden LATER tools) is still a GROUP + // even when only one tool is visible - so a one-item first/middle/last window never collapses a + // full-source activity group into a standalone row. + var continuesAfter = end == entries.Count && context.LeadingGroupContinuesAfter; + if (count == 1 && !isLeadingSpan && !continuesAfter) { - rows.Add(Standalone(entry, sessionId, timelineGeneration)); + rows.Add(Standalone(entry, sessionId, timelineGeneration) with { Pending = isPending }); } else { var tools = entries.Skip(index).Take(count).ToArray(); + var firstToolId = isLeadingSpan + ? context.LeadingGroupFirstToolId! + : tools[0].Id; rows.Add(new ChatToolActivityRow( - ActivityKey(sessionId, timelineGeneration, tools[0].Id), + ActivityKey(sessionId, timelineGeneration, firstToolId), null, tools, - Summarize(tools))); + Summarize(tools), + isLeadingSpan ? context.LeadingHiddenToolCount : 0, + continuesAfter, + isPending)); } index = end; @@ -111,6 +140,431 @@ public static IReadOnlyList Project( public static string ActivityKey(string? sessionId, long timelineGeneration, string firstToolEntryId) => $"thread:{sessionId ?? "none"}|generation:{timelineGeneration}|activity:{firstToolEntryId}"; + /// + /// A single assistant run span within a (possibly clipped) visible slice. + /// + public readonly record struct ChatAssistantRunSpan(bool IsStart, bool IsEnd); + + /// + /// Source-faithful assistant-run continuation policy shared by the ACTUAL timeline consumer and the tests. + /// A run clipped at the FRONT of a bounded window CONTINUES a run that began earlier in the full accepted + /// history, so it is not a run START (continuation/finalization semantics stay correct across window moves). + /// + public static class ChatAssistantRunPolicy + { + public static IReadOnlyList Describe( + IReadOnlyList entries, + bool leadingRunContinuesBefore = false, + bool trailingRunContinuesAfter = false) + { + var spans = new ChatAssistantRunSpan[entries.Count]; + for (var index = 0; index < entries.Count; index++) + { + if (entries[index].Kind != ChatTimelineItemKind.Assistant) + continue; + var isStart = index == 0 + ? !leadingRunContinuesBefore + : entries[index - 1].Kind != ChatTimelineItemKind.Assistant; + // A run clipped at the BACK of the window CONTINUES past the window, so the last visible + // assistant is not a run END either. + var isEnd = index == entries.Count - 1 + ? !trailingRunContinuesAfter + : entries[index + 1].Kind != ChatTimelineItemKind.Assistant; + spans[index] = new ChatAssistantRunSpan(isStart, isEnd); + } + return spans; + } + } + + /// + /// Source-faithful window context for a bounded visible slice of the FULL accepted timeline. It carries the + /// LOGICAL boundary facts (a group/run may begin BEFORE and/or continue AFTER the window) so the bounded + /// projection never invents a new activity run at the clip boundary. Plain value: it holds no reference to + /// the retained root. + /// + public sealed record ChatToolActivityWindowContext( + string? LeadingGroupFirstToolId, + int LeadingHiddenToolCount, + bool LeadingGroupContinuesAfter, + bool LeadingAssistantRunContinuesBefore, + bool LeadingAssistantRunContinuesAfter, + bool Provisional = false) + { + public static readonly ChatToolActivityWindowContext None = new(null, 0, false, false, false); + } + + /// + /// Computes source-faithful activity/run context for a bounded visible window over the full accepted + /// timeline. Uses a BOUNDED resume from the window edges (never a full-retained index on the UI render + /// path) plus a small fixed-capacity cache keyed by a WEAK root identity and the window coordinates and + /// generation, so repeated renders/moves reuse the facts. The weak-root table never lets the cache keep an + /// abandoned retained root alive (same discipline as the projection cache). + /// + public sealed class ChatToolActivitySourceFacts + { + private sealed class RootToken + { + } + + private readonly int _capacity; + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _tokens = new(); + private readonly Dictionary<(RootToken Root, int Start, int End, long Generation), ChatToolActivityWindowContext> _facts = new(); + private readonly Queue<(RootToken Root, int Start, int End, long Generation)> _order = new(); + private readonly object _sync = new(); + private readonly Dictionary _activeGeneration = new(); + private readonly Queue _activeGenerationOrder = new(); + + public ChatToolActivitySourceFacts(int capacity = 64) + { + _capacity = Math.Max(1, capacity); + } + + /// + /// Hard bound on synchronous edge-resume work per render. A window whose logical span exceeds this + /// renders with PROVISIONAL facts and the full resume is completed asynchronously OFF the UI thread. + /// + public const int BoundedResumeOperations = 256; + + /// Synchronous, UNBOUNDED resume (deterministic; tests and the async completion use this). + public ChatToolActivityWindowContext ForWindow( + IReadOnlyList full, int windowStart, int windowEnd, long generation, + CancellationToken cancellationToken = default) => + Resolve(full, windowStart, windowEnd, generation, int.MaxValue, cancellationToken); + + /// + /// Bounded synchronous fast path for the UI render: never scans more than BoundedResumeOperations, so + /// the render cannot become O(full retained history). Facts are Provisional when the bound was hit. + /// + public ChatToolActivityWindowContext ForWindowBounded( + IReadOnlyList full, int windowStart, int windowEnd, long generation, + CancellationToken cancellationToken = default) => + Resolve(full, windowStart, windowEnd, generation, BoundedResumeOperations, cancellationToken); + + /// + /// Completes a PROVISIONAL window off the UI thread (generation/cancellation guarded) and invokes + /// onResolved once with faithful facts. Never runs a full-retained scan on the render path. + /// + /// + /// Schedules the off-UI completion. Returns FALSE when the facts are ALREADY faithful (nothing was + /// started), so the caller can read the bounded path again instead of waiting for a lost wakeup. + /// + public bool ScheduleCompletion( + IReadOnlyList full, int windowStart, int windowEnd, long generation, + CancellationToken cancellationToken, Action onResolved) + { + ArgumentNullException.ThrowIfNull(onResolved); + var start = Math.Clamp(windowStart, 0, full.Count); + var end = Math.Clamp(windowEnd, start, full.Count); + var token = _tokens.GetValue(full, static _ => new RootToken()); + lock (_sync) + { + RecordActiveGenerationLocked(token, generation); + var key = (token, start, end, generation); + if (_facts.TryGetValue(key, out var cached) && !cached.Provisional) + return false; // already faithful: no background work; caller reads the bounded path + // NO per-key pending gate: a superseded (cancelled) flight for the same key must not block a + // NEW active flight from taking over. The owning ChatToolActivityRenderCoordinator keeps + // exactly one flight per active lease; a cancelled flight returns early on its token. + } + _ = Task.Run(() => + { + if (cancellationToken.IsCancellationRequested) + return; + // Compute OUTSIDE the lock; the full resume must never block the UI fast path. + var completed = Compute(full, start, end, int.MaxValue, cancellationToken); + if (completed.Provisional || cancellationToken.IsCancellationRequested) + return; + if (!IsActiveGeneration(token, generation)) + return; // superseded (session/root/generation changed): fence the callback + Publish(token, start, end, generation, completed); + onResolved(); // off the render path, only when still current + }, CancellationToken.None); + return true; + } + + private ChatToolActivityWindowContext Resolve( + IReadOnlyList full, int windowStart, int windowEnd, long generation, + int maxOperations, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(full); + var start = Math.Clamp(windowStart, 0, full.Count); + var end = Math.Clamp(windowEnd, start, full.Count); + var token = _tokens.GetValue(full, static _ => new RootToken()); + lock (_sync) + { + RecordActiveGenerationLocked(token, generation); + if (_facts.TryGetValue((token, start, end, generation), out var cached) && !cached.Provisional) + return cached; + } + // Compute OUTSIDE the lock so the short cache lock is never held during a long scan. + var computed = Compute(full, start, end, maxOperations, cancellationToken); + if (!computed.Provisional) + Publish(token, start, end, generation, computed); + return computed; + } + + private void Publish( + RootToken token, int start, int end, long generation, ChatToolActivityWindowContext computed) + { + lock (_sync) + { + var key = (token, start, end, generation); + if (_facts.TryGetValue(key, out var existing) && !existing.Provisional) + return; // never regress a faithful entry + _facts[key] = computed; + _order.Enqueue(key); + while (_order.Count > _capacity) + _facts.Remove(_order.Dequeue()); + } + } + + private const int MaxActiveGenerations = 64; + + private void RecordActiveGenerationLocked(RootToken token, long generation) + { + // Bounded on ALL paths: a provisional/cancelled root that never publishes still records here, so + // the map is capped (oldest first) rather than relying on the facts-cache eviction. + if (!_activeGeneration.ContainsKey(token)) + { + _activeGenerationOrder.Enqueue(token); + while (_activeGenerationOrder.Count > MaxActiveGenerations) + _activeGeneration.Remove(_activeGenerationOrder.Dequeue()); + } + _activeGeneration[token] = + _activeGeneration.TryGetValue(token, out var current) ? Math.Max(current, generation) : generation; + } + + public int ActiveGenerationCount => _activeGeneration.Count; + + private bool IsActiveGeneration(RootToken token, long generation) + { + lock (_sync) + return _activeGeneration.TryGetValue(token, out var active) && active == generation; + } + + private static ChatToolActivityWindowContext Compute( + IReadOnlyList full, int start, int end, int maxOperations, + CancellationToken cancellationToken) + { + static bool IsLiveTool(ChatTimelineItem entry) => + entry.Kind == ChatTimelineItemKind.ToolCall && + entry.ToolResult != ChatToolCallStatus.Error; + + // HARD operation budget for the synchronous edge resume. When the budget is exceeded the facts so + // far are returned PROVISIONAL and the full resume is completed asynchronously - the logical source + // is never silently truncated. + var operations = 0; + var provisional = false; + bool Budget() + { + if (++operations > maxOperations) + return false; + if ((operations & 63) == 0 && cancellationToken.IsCancellationRequested) + { + provisional = true; + return false; + } + return true; + } + + string? leadingFirstToolId = null; + var leadingHidden = 0; + if (start < end && IsLiveTool(full[start])) + { + var i = start - 1; + while (i >= 0 && Budget() && IsLiveTool(full[i])) + { + leadingHidden++; + i--; + } + if (i >= 0 && IsLiveTool(full[i])) + provisional = true; // budget hit before the true span start + else if (leadingHidden > 0) + leadingFirstToolId = full[i + 1].Id; + } + + var leadingGroupContinuesAfter = false; + var leadingRunContinuesBefore = false; + var leadingRunContinuesAfter = false; + if (start < end) + { + var i = end; + while (i < full.Count && Budget() && IsLiveTool(full[i])) + i++; + if (i < full.Count && IsLiveTool(full[i])) + provisional = true; + leadingGroupContinuesAfter = i > end; + leadingRunContinuesBefore = + full[start].Kind == ChatTimelineItemKind.Assistant && + start > 0 && + full[start - 1].Kind == ChatTimelineItemKind.Assistant; + leadingRunContinuesAfter = + full[end - 1].Kind == ChatTimelineItemKind.Assistant && + end < full.Count && + full[end].Kind == ChatTimelineItemKind.Assistant; + } + + return new ChatToolActivityWindowContext( + leadingFirstToolId, leadingHidden, leadingGroupContinuesAfter, + leadingRunContinuesBefore, leadingRunContinuesAfter, provisional); + } + } + + /// + /// A single render lease: the EXACT (session, retained root, window, generation) a projection belongs to, + /// with its own cancellation source. Owned by ChatToolActivityRenderCoordinator. + /// + public sealed class ChatToolActivityRenderLease + { + internal ChatToolActivityRenderLease( + string? sessionId, IReadOnlyList root, int start, int end, long generation, + long version) + { + SessionId = sessionId; + Root = root; + Start = start; + End = end; + Generation = generation; + Version = version; + } + + public string? SessionId { get; } + public IReadOnlyList Root { get; } + public int Start { get; } + public int End { get; } + public long Generation { get; } + internal long Version { get; } + internal CancellationTokenSource Cancellation { get; } = new(); + } + + /// + /// Lifecycle owner of the bounded activity/run fact work for ONE chat root. Begin(...) is called EVERY + /// render and cancels + supersedes the previous lease BEFORE any bounded compute, so a long provisional + /// window replaced by a short faithful window - or a different session/root/window/edit/generation - can + /// never publish or re-render. Dispose() cancels everything on unmount. + /// + public sealed class ChatToolActivityRenderCoordinator : IDisposable + { + private readonly ChatToolActivitySourceFacts _facts; + private readonly object _sync = new(); + private ChatToolActivityRenderLease? _active; + private ChatToolActivityRenderLease? _flight; // the ONE lease with a completion in flight + private long _renderVersion; + private bool _disposed; + + public ChatToolActivityRenderCoordinator(ChatToolActivitySourceFacts facts) => + _facts = facts ?? throw new ArgumentNullException(nameof(facts)); + + public ChatToolActivityRenderLease Begin( + string? sessionId, IReadOnlyList root, int start, int end, long generation) + { + lock (_sync) + { + // REUSE the lease for an IDENTICAL logical scope (session UUID + retained root + window + + // generation): an unrelated same-scope render must not cancel/restart in-flight work. + if (_active is { } current && + string.Equals(current.SessionId, sessionId, StringComparison.Ordinal) && + ReferenceEquals(current.Root, root) && + current.Start == start && + current.End == end && + current.Generation == generation) + { + return current; + } + + _active?.Cancellation.Cancel(); // DIFFERENT scope: invalidate the previous lease + var version = ++_renderVersion; + var lease = new ChatToolActivityRenderLease(sessionId, root, start, end, generation, version); + if (_disposed) + { + lease.Cancellation.Cancel(); + return lease; + } + _active = lease; + return lease; + } + } + + /// + /// Bounded synchronous facts for the lease, or NULL when the window needs the async completion + /// (PENDING). Never scans the full retained history and never invents identity from provisional facts. + /// + public ChatToolActivityWindowContext? TryBounded(ChatToolActivityRenderLease lease) + { + var context = _facts.ForWindowBounded( + lease.Root, lease.Start, lease.End, lease.Generation, lease.Cancellation.Token); + return context.Provisional ? null : context; + } + + /// + /// Schedules the off-UI completion. onResolved runs ONLY when this exact lease is still the active one + /// (fence checked atomically against the render version), so a superseded window/session cannot + /// re-render or publish. + /// + /// + /// Returns TRUE when a completion flight was started; FALSE when the facts are already faithful (or + /// the lease is superseded) - the caller then reads the bounded path instead of awaiting a wakeup. + /// + public bool RequestCompletion(ChatToolActivityRenderLease lease, Action onResolved) + { + ArgumentNullException.ThrowIfNull(onResolved); + lock (_sync) + { + if (_disposed || !ReferenceEquals(_active, lease)) + return false; // superseded: never start work for a stale lease + if (ReferenceEquals(_flight, lease)) + return true; // one owned flight per lease + _flight = lease; + } + var scheduled = _facts.ScheduleCompletion( + lease.Root, lease.Start, lease.End, lease.Generation, lease.Cancellation.Token, + () => + { + lock (_sync) + { + if (!ReferenceEquals(_flight, lease)) + return; // a newer flight for this lease already committed + _flight = null; + if (_disposed || !ReferenceEquals(_active, lease)) + return; // lease superseded while the flight drained: do not commit + // COMMIT under the same fence the check used: no post-check callback race. + onResolved(); + } + }); + if (!scheduled) + { + lock (_sync) + { + if (ReferenceEquals(_flight, lease)) + _flight = null; // nothing to own: facts were already faithful + } + } + return scheduled; + } + + /// Cancels in-flight work but keeps this coordinator USABLE (provider change, not unmount). + public void Reset() + { + lock (_sync) + { + _active?.Cancellation.Cancel(); + _active = null; + _flight = null; + } + } + + public void Dispose() + { + lock (_sync) + { + _disposed = true; + _active?.Cancellation.Cancel(); + _active = null; + _flight = null; + } + } + } + public static ChatToolActivitySummary Summarize(IReadOnlyList tools) { var categoryCounts = new Dictionary(); diff --git a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs index d9329b408e..6ac62faa85 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs @@ -85,6 +85,17 @@ Task CompactSessionDetailedAsync(string sessionKey) => /// Task ClearSessionThinkingLevelAsync(string sessionKey); Task RequestChatHistoryAsync(string? sessionKey); + /// + /// Bounded, typed chat.history page (limit/maxBytes/offset) carrying source-shaped paging metadata. + /// The explicit unsupported default keeps existing implementers source-compatible; production + /// bridges override it. Callers must not treat a bounded tail as complete history. + /// + Task RequestChatHistoryPageAsync( + string? sessionKey, + ChatHistoryPageOptions options, + CancellationToken cancellationToken = default) => + Task.FromException( + new NotSupportedException("Bounded chat.history pages are not supported by this chat bridge.")); Task ResolveAssistantMediaAsync( string sessionKey, ChatMediaContentInfo media, @@ -253,6 +264,12 @@ public Task RequestSessionsAsync() => public Task RequestChatHistoryAsync(string? sessionKey) => _client.RequestChatHistoryAsync(sessionKey); + public Task RequestChatHistoryPageAsync( + string? sessionKey, + ChatHistoryPageOptions options, + CancellationToken cancellationToken = default) => + _client.RequestChatHistoryPageAsync(sessionKey, options, cancellationToken: cancellationToken); + public Task ResolveAssistantMediaAsync( string sessionKey, ChatMediaContentInfo media, diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 9f5485868f..774e909356 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -730,6 +730,135 @@ public Task LoadHistoryAsync( cancellationToken, authoritative); + /// + /// Load-earlier presentation bookkeeping, bound to the exact accepted window (token + revision) + /// captured when the request started. Guarded by its own lock and never held across state reads, + /// snapshots, callbacks or awaits, so a delayed failure can never attach to a newer window. + /// + private sealed record LoadOlderOperation(ChatHistoryWindowLease Lease, bool InFlight, bool Failed); + + private readonly object _loadOlderGate = new(); + private readonly Dictionary _loadOlder = new(StringComparer.Ordinal); + + /// + /// Explicit load-earlier state for a thread, bound to the CURRENT accepted window. Loading is only + /// reported while the tracked operation still matches the current window; a stale operation is + /// pruned. Unknown/unavailable (no accepted window) is never reported as exhaustion. + /// + internal ChatLoadOlderState GetLoadOlderState(string threadId) + { + // Capture the CURRENT accepted lease (full token + revision + window) OUTSIDE the bookkeeping + // lock. Comparing only the paging tuple is insufficient: a forced initial refresh can commit an + // identical tuple under a NEWER revision, and an old operation must not colour it. + var current = _state.TryCaptureHistoryWindowLease(threadId, out var captured) + ? captured + : (ChatHistoryWindowLease?)null; + LoadOlderOperation? tracked; + lock (_loadOlderGate) + { + if (current is { } lease && + _loadOlder.TryGetValue(threadId, out var candidate) && + Equals(candidate.Lease, lease)) + { + tracked = candidate; + } + else + { + // No current window, or a newer revision/identity (even with an identical paging + // tuple), or reset/reconnect/exhaustion: the tracked operation is stale and is pruned. + _loadOlder.Remove(threadId); + tracked = null; + } + } + + if (tracked is { InFlight: true }) + return ChatLoadOlderState.Loading; + if (current is null) + return ChatLoadOlderState.Unavailable; + if (!current.Value.Window.HasMore) + return ChatLoadOlderState.Exhausted; + return tracked is { Failed: true } ? ChatLoadOlderState.Error : ChatLoadOlderState.Available; + } + + /// + /// Loads ONE older bounded page for the thread (single-flight per accepted window, server-supplied + /// offset, exhaustion no-op, no unbounded fallback). Faults are observed here and surfaced as a + /// generic retryable Error only while the SAME accepted window is still current; cancellation is not + /// an error; the held transcript and accepted window are always preserved. + /// + public async Task LoadOlderAsync(string threadId, CancellationToken cancellationToken = default) + { + if (!_state.TryCaptureHistoryWindowLease(threadId, out var lease)) + return; + if (!lease.Window.HasMore || lease.Window.NextOffset is null) + return; + + lock (_loadOlderGate) + { + if (_loadOlder.TryGetValue(threadId, out var existing) && existing.InFlight) + return; // single-flight: loading disables a repeated action + _loadOlder[threadId] = new LoadOlderOperation(lease, InFlight: true, Failed: false); + } + + PublishCurrentSnapshot(); + try + { + await _historyLoader.LoadOlderAsync(threadId, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Cancellation: held transcript/window preserved; not an error state. + } + catch (Exception) + { + // Record a generic error ONLY if the CAPTURED lease (token + revision + window) is still the + // accepted one. A delayed failure against a superseded revision (even an identical paging + // tuple) must not colour the new presentation. + var current = _state.TryCaptureHistoryWindowLease(threadId, out var captured) + ? captured + : (ChatHistoryWindowLease?)null; + lock (_loadOlderGate) + { + if (_loadOlder.TryGetValue(threadId, out var op) && + Equals(op.Lease, lease) && + current is { } leaseNow && Equals(op.Lease, leaseNow)) + { + _loadOlder[threadId] = op with { Failed = true }; + } + } + } + finally + { + lock (_loadOlderGate) + { + if (_loadOlder.TryGetValue(threadId, out var op) && Equals(op.Lease, lease)) + _loadOlder[threadId] = op with { InFlight = false }; + } + PublishCurrentSnapshot(); + } + } + + private void PublishCurrentSnapshot() + { + // Refresh republish only: it never mutates state and never surfaces raw detail. A failure here + // means the UI did not get a repaint notification; it does not by itself prove the retry state + // was seen, and it must not be reported as if it were. + try + { + Publish(_state.Snapshot(ProjectionContext())); + } + catch (Exception) + { + } + } + + /// + /// The accepted bounded page window for a thread, or null when unknown/unavailable (never + /// silently reported as complete). + /// + internal ChatHistoryWindowState? GetHistoryWindow(string threadId) => + _state.GetHistoryWindow(threadId); + internal Task ReplaceHistoryAfterCheckpointRestoreAsync( string threadId, CancellationToken cancellationToken = default) @@ -754,11 +883,16 @@ private void OnHistoryLoadCompleted( { void Deliver() { - var snapshot = _state.SnapshotIfHistoryTokenCurrent( - result.Token, + // Source-owned fence: generation token AND the full original window lease. QueueFencedDelivery + // re-runs this at DELIVERY time, so a same-cursor refresh landing between emit and delivery + // still fences the obsolete error. + var snapshot = _state.SnapshotIfHistoryResultCurrent( + result, ProjectionContext()); if (snapshot is null) return; + // (The usage/topology revision is bumped INSIDE the state gate on accepted commits, so a + // reader can never observe new state with a stale revision.) if (result.PublishSnapshot) DeliverPublishBatch( snapshot, @@ -1099,6 +1233,94 @@ public ValueTask DisposeAsync() public IReadOnlyDictionary GetEntryMetadata(string threadId) => _state.GetEntryMetadata(threadId); + /// + /// Latest qualifying assistant usage over the FULL retained timeline (never the windowed slice), so + /// windowing cannot lose it nor fall back to a stale thread summary. Cached per thread; invalidated by + /// entries-reference, metadata revision or session identity change (see ChatLatestUsageProjection). + /// + /// + /// Global latest qualifying usage for a thread. The COHERENT state-owned retained input (timeline + + /// accepted identity + usage revision captured under one lock) is passed to the projection, so an older + /// timeline can never be cached against a newer revision/metadata. Returns the summary AND the global + /// latest entry id so consumers label only that row. + /// + private readonly System.Collections.Concurrent.ConcurrentDictionary _usageFlights = + new(StringComparer.Ordinal); + + /// Test-only seam: invoked once immediately before the flight publishes (null in production). + internal Action? BeforeUsageFlightPublishForTests { get; set; } + + public (string? Summary, string? EntryId) GetLatestUsage(string threadId) + { + // The STATE computes this coherently under its single lock (bounded, resumable per call), so there + // is no window in which newer metadata is combined with an older timeline/revision. This method is + // NOT the job driver: when the scan is incomplete it starts ONE owned background flight that drives + // the bounded slices to completion and publishes, so no further render/request is required. + var (summary, entryId, complete) = _state.AdvanceLatestUsage(threadId); + if (!complete) + StartUsageFlight(threadId); + return (summary, entryId); + } + + /// + /// One owned background flight per thread advances the bounded usage scan (fixed slice budget per + /// step, yielding between slices, never holding the state lock across await) to completion and then + /// publishes the accepted snapshot, so completion is autonomous and does not depend on a future + /// re-render. The flight ends early when the state is disposed (cancellation on dispose). + /// + private void StartUsageFlight(string threadId) + { + if (_state.IsDisposed || !_usageFlights.TryAdd(threadId, 0)) + return; // single-flight per thread + _ = Task.Run(async () => + { + try + { + while (!_state.IsDisposed) + { + if (!_state.AdvanceLatestUsage(threadId).Complete) + { + await Task.Yield(); // bounded slice done; let the pool/UI breathe + continue; + } + // Re-verify at completion: a mutation (metadata/topology/identity) that arrived right + // at the boundary must RESTART the scan rather than be lost because the flight key was + // already present. A zero-step probe stays incomplete when the scan was restarted. + if (_state.AdvanceLatestUsage(threadId, maxSteps: 0).Complete) + break; + } + if (_state.IsDisposed) + return; // dispose cancels: never publish after disposal + BeforeUsageFlightPublishForTests?.Invoke(); + if (_state.IsDisposed) + return; + PublishCurrentSnapshot(); + } + catch (Exception) + { + // Never fault on a background usage flight. + } + finally + { + _usageFlights.TryRemove(threadId, out _); + // HANDOFF SHAKE: a mutation that arrived while we owned the key (e.g. during publication) + // may have restarted the scan. Re-acquire continuation so pending work is never left + // unowned just because the key was present when the new request came in. A removed thread + // resolves Complete, so no flight is leaked for it. + if (!_state.IsDisposed && !_state.AdvanceLatestUsage(threadId, maxSteps: 0).Complete) + StartUsageFlight(threadId); + } + }); + } + + /// Bounded metadata snapshot for the visible entries only. + public IReadOnlyDictionary GetVisibleEntryMetadata( + string threadId, IReadOnlyCollection entryIds) => + _state.GetEntryMetadataFor(threadId, entryIds); + + /// Drops the cached usage projection for a thread (reset / identity change). + public void ForgetLatestUsage(string threadId) => _state.ForgetUsageScan(threadId); + internal Task ResolveAssistantMediaAsync( string sessionKey, ChatMediaContentInfo media, @@ -1140,8 +1362,9 @@ private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) { if (_state.IsDisposed) return; + var updatedSessions = sessions ?? []; var transition = _state.ApplySessions( - sessions ?? [], + updatedSessions, ProjectionContext()); Publish(transition.Snapshot); @@ -1299,7 +1522,8 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) message.OpenClawSeq, openClawKind: message.OpenClawKind, compactionTokensBefore: message.CompactionTokensBefore, - compactionTokensAfter: message.CompactionTokensAfter)); + compactionTokensAfter: message.CompactionTokensAfter, + gatewayDisplayItemId: message.OpenClawDisplayItemId)); return; } diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs index 1348a13144..d27a6e18ff 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs @@ -43,6 +43,37 @@ public sealed class OpenClawReactorChatRoot : Component(initialSelection); selectedIdRef.Current = selectedId; var (scrollToBottomToken, setScrollToBottomToken) = UseState(0, threadSafe: true); + var (displayWindowVersion, setDisplayWindowVersion) = UseState(0, threadSafe: true); + // Re-render trigger for the bounded async activity/run fact completion (off the render path). + var (toolFactsVersion, setToolFactsVersion) = UseState(0L, threadSafe: true); var (showToolCalls, setShowToolCalls) = UseState(s_showToolCalls, threadSafe: true); var (toolCallsCollapseVersion, setToolCallsCollapseVersion) = UseState(s_toolCallsCollapseVersion, threadSafe: true); @@ -89,10 +123,28 @@ public override Element Render() { setSnapshot(args.Snapshot); if (args.Snapshot.ComposeTarget.SessionKey is { } composeKey - && args.Snapshot.Timelines.TryGetValue(composeKey, out var timeline) - && timeline.Entries.Any(entry => entry.Kind == ChatTimelineItemKind.User)) + && args.Snapshot.Timelines.TryGetValue(composeKey, out var timeline)) + { + // Bounded facts on the UI callback path (no synchronous full-scan Get). The completion reads + // the FAITHFUL facts and clears first-send under the EXACT current lease (composeKey/root), + // so a deep user resolves WITHOUT another provider.Changed callback. Unknown while pending + // never clears first-send. + var factsLease = _firstSendFactsLifecycle.Begin(composeKey, timeline.Entries, 0); + _ = _firstSendFactsLifecycle.RequestCompletion( + factsLease, + ClearFirstSendIfUserPresent); + ClearFirstSendIfUserPresent(); + + void ClearFirstSendIfUserPresent() + { + if (_firstSendFactsLifecycle.TryBounded(factsLease)?.HasAnyUser == true) + setFirstSendInFlight(false); + } + } + else if (args.Snapshot.ComposeTarget.SessionKey is null) { - setFirstSendInFlight(false); + // Compose/producer changed to null: invalidate any in-flight first-send work. + _ = _firstSendFactsLifecycle.Begin(null, Array.Empty(), 0); } if (selectedIdRef.Current is null && args.Snapshot.DefaultThreadId is { } defaultThreadId) @@ -112,9 +164,27 @@ public override Element Render() selectedIdRef.Current = next; setSelectedId(next); }); - return () => provider.Changed -= onChanged; + return () => + { + provider.Changed -= onChanged; + // PROVIDER CHANGE (not unmount): CANCEL in-flight work but keep the lifecycles USABLE with + // the new provider. A permanent Dispose here would leave the fields inert. + _retainedFactsLifecycle.Reset(); + _firstSendFactsLifecycle.Reset(); + _anchorResolution.Reset(); + _activityLifecycle.Reset(); + }; }), props.Provider); + // FINAL UNMOUNT only: permanently dispose the owned lifecycles. + UseEffect((Func)(() => () => + { + _retainedFactsLifecycle.Dispose(); + _firstSendFactsLifecycle.Dispose(); + _anchorResolution.Dispose(); + _activityLifecycle.Dispose(); + }), Array.Empty()); + if (snapshot is null) return RenderLoading(); @@ -160,27 +230,135 @@ public override Element Render() && snapshot.HistoryRevisions?.TryGetValue(effectiveThread.Id, out var revision) == true ? revision : 0L; + var entries = (IReadOnlyList)timeline.Entries; + // STRUCTURED identity: authoritative provider REFERENCE + thread + the AUTHORITATIVE accepted session + // UUID published in the SAME snapshot (with the same coherent timeline). Unknown UUID is EXPLICIT and + // never reuses another materialized identity. The history revision is deliberately NOT part of the + // identity, so ordinary older pages (which advance it) do not discard the held view. + var acceptedUuid = effectiveThread is { } uuidThread && snapshot.AcceptedSessionIds is { } uuids && + uuids.TryGetValue(uuidThread.Id, out var uuidValue) + ? uuidValue + : null; + // AUTHORITATIVE provider REFERENCE change: discard ALL navigation identities/anchors owned by the + // previous provider (not only the current thread) and reset the same-root anchor lifecycle, BEFORE + // ensuring/resolving below. Otherwise a previously-selected thread's identity keeps rooting the old + // provider graph. The SAME provider/thread/UUID preserves ordinary page navigation. + if (!ReferenceEquals(_stableWindowProviderRef, props.Provider)) + { + _stableWindowProviderRef = props.Provider; + _stableWindowScope.Clear(); // provider replaced: discard any held window + _navigation.Clear(); // release ALL old-provider identities + anchors + _anchorResolution.Reset(); // cancel any in-flight anchor flight (provider change, not unmount) + } + // INVALIDATE the scoped navigation identity BEFORE computing THIS render's window. A UUID/provider + // transition on the SAME thread drops a pre-reset resolved ANCHOR even when NO anchor flight is pending + // (the previous lease-based check only fired while a flight was active). The SAME identity preserves the + // thread's navigation across ordinary older pages. + if (effectiveThread is { } identityThread) + _navigation.EnsureNavigationIdentity(props.Provider, identityThread.Id, acceptedUuid); + // Bounded MOVING display window: the expensive per-render projection consumes only a fixed-size + // window, so per-render CPU is bounded by the window rather than the retained history length. + // The complete logical/durable history stays intact in state/snapshot (never truncated). The + // portable navigation seam supplies the anchored window; the cache avoids recounting it. + _ = displayWindowVersion; // re-render dependency for navigation moves + var navigationWindow = _navigation.Resolve( + effectiveThread?.Id, timeline, ChatTimelineDisplayWindowPolicy.DefaultWindowSize); + // DEEP pending anchor: resolve the source position OFF the UI path (complete index, one owned flight) + // and re-render on completion. While pending we DO NOT feed the tail/HiddenEarlierCount into the + // projection cache, so the display never shows a fake resolved jump. + var anchorLease = effectiveThread is null + ? null + : _anchorResolution.Begin( + effectiveThread.Id, timeline, ChatTimelineDisplayWindowPolicy.DefaultWindowSize, acceptedUuid); + if (anchorLease is not null) + { + _ = _anchorResolution.TryResolvePending( + timeline, + () => setToolFactsVersion(Interlocked.Increment(ref _toolFactsCompletionVersion))); + } + var stableOrResolved = navigationWindow.Pending + ? navigationWindow + : _displayWindowCache.Get( + effectiveThread?.Id, + timeline, + timelineGeneration, + ChatTimelineDisplayWindowPolicy.DefaultWindowSize, + navigationWindow.HiddenEarlierCount + navigationWindow.VisibleEntries.Count); + // PENDING presentation: hold the last stable bounded window (or an explicit loading window) - NEVER the + // new tail / stale offset as if resolved. + // (provider replacement was handled BEFORE resolving the window, see above) + // STRUCTURED scope key: provider REFERENCE + thread + accepted UUID (revision deliberately excluded). + var stableKey = new ChatTimelineWindowScopeKey(props.Provider, effectiveThread?.Id, acceptedUuid); + var displayWindow = ChatTimelinePendingPresentationPolicy.Resolve( + _stableWindowScope.Get(stableKey), stableOrResolved); + if (!displayWindow.Pending) + _stableWindowScope.Set(stableKey, displayWindow); // scope-checked: never across identities + entries = displayWindow.VisibleEntries; + var hiddenEarlierEntries = displayWindow.HiddenEarlierCount; + var hiddenLaterEntries = displayWindow.HiddenLaterCount; + // SOURCE-FAITHFUL window context: resolve the logical activity-group / assistant-run continuation + // facts from the FULL retained timeline at the window edges only (bounded resume, cached). This keeps + // stable activity identity across moved/cut windows without a per-render full-retained scan. + _ = toolFactsVersion; // re-render dependency once async facts are published + var windowStart = hiddenEarlierEntries; + var windowEnd = hiddenEarlierEntries + displayWindow.VisibleEntries.Count; + // Invalidate the PREVIOUS render lease on EVERY render (any session/root/window/edit/generation change), + // BEFORE any compute, so a superseded long provisional window can never publish or re-render. Then take + // the BOUNDED facts (never O(full history)); a provisional window yields null => clear PENDING state. + ChatToolActivityPresentation.ChatToolActivityWindowContext? toolActivityWindow; + bool toolActivityPending; + if (displayWindow.Pending) + { + // The visible window is NOT this root's slice (deep anchor pending): do NOT query activity facts + // with stale held offsets against the current timeline. Invalidate the obsolete lease and report an + // explicit PENDING (null) fact so the UI holds instead of binding wrong identities. + _ = _activityLifecycle.Begin( + effectiveThread?.Id, Array.Empty(), 0, 0, timelineGeneration); + toolActivityWindow = null; + toolActivityPending = true; + } + else + { + var activityLease = _activityLifecycle.Begin( + effectiveThread?.Id, timeline.Entries, windowStart, windowEnd, timelineGeneration); + // Register the completion BEFORE reading the bounded facts so a fact that becomes faithful between + // the read and the request can never lose its wakeup; RequestCompletion returns false when faithful. + _ = _activityLifecycle.RequestCompletion( + activityLease, + () => setToolFactsVersion(Interlocked.Increment(ref _toolFactsCompletionVersion))); + toolActivityWindow = _activityLifecycle.TryBounded(activityLease); + toolActivityPending = toolActivityWindow is null; + } + + // Bounded metadata snapshot for the VISIBLE entries only (no full retained-dictionary copy under + // the state lock), plus the full-retained latest qualifying usage so windowing cannot lose it. var entryMetadata = effectiveThread is not null && props.Provider is OpenClawChatDataProvider metadataProvider - ? metadataProvider.GetEntryMetadata(effectiveThread.Id) + ? metadataProvider.GetVisibleEntryMetadata( + effectiveThread.Id, + displayWindow.VisibleEntries.Select(entry => entry.Id).ToArray()) : null; - var entries = (IReadOnlyList)timeline.Entries; + var latestUsage = effectiveThread is not null && props.Provider is OpenClawChatDataProvider usageProvider + ? usageProvider.GetLatestUsage(effectiveThread.Id) + : (Summary: (string?)null, EntryId: (string?)null); + var usageSummary = latestUsage.Summary; var queuedMessages = effectiveThread is not null && snapshot.QueuedMessagesByThread?.TryGetValue(effectiveThread.Id, out var queued) == true ? queued : Array.Empty(); var hasPendingQueuedSend = queuedMessages.Any(message => message.SendState is ChatQueuedMessageSendState.Queued or ChatQueuedMessageSendState.Sending); - var currentTurnHasAssistant = false; - for (var index = timeline.Entries.Count - 1; index >= 0; index--) - { - if (timeline.Entries[index].Kind == ChatTimelineItemKind.User) - break; - if (timeline.Entries[index].Kind == ChatTimelineItemKind.Assistant) - { - currentTurnHasAssistant = true; - break; - } - } + // Source-faithful retained facts (weak-root-keyed, bounded fast path + off-UI completion) replace the + // per-render full-retained backward scan. While PENDING the flag is UNKNOWN: do NOT invent a value - + // treat unknown as "assistant may exist" so a false thinking spinner is never shown. + var factsLease = _retainedFactsLifecycle.Begin( + effectiveThread?.Id, timeline.Entries, timelineGeneration); + // Register the completion BEFORE reading the bounded facts, so a fact becoming faithful between the + // read and the request can never lose its wakeup (RequestCompletion returns false when faithful). + _ = _retainedFactsLifecycle.RequestCompletion( + factsLease, + () => setToolFactsVersion(Interlocked.Increment(ref _toolFactsCompletionVersion))); + var retainedFacts = _retainedFactsLifecycle.TryBounded(factsLease); + var currentTurnHasAssistant = retainedFacts?.CurrentTurnHasAssistant ?? true; var showThinking = timeline.TurnActive && !currentTurnHasAssistant; var isEmptyConversation = entries.Count == 0 && queuedMessages.Count == 0 @@ -240,18 +418,50 @@ public override Element Render() ? dataProvider.ResolveAssistantMediaAsync : null; + // Load-earlier is wired from the accepted bounded window of the selected thread only. The + // thread identity is captured at action time; unknown/unavailable windows never claim more. + // Load-earlier is driven by the explicit state of the SELECTED thread only. The thread identity + // is captured at action time; a fault is observed (never an unobserved task); the accepted + // server offset is unchanged and no unbounded collection is kept. + var loadOlderState = effectiveThread is { } stateThread && + props.Provider is OpenClawChatDataProvider stateProvider + ? stateProvider.GetLoadOlderState(stateThread.Id) + : ChatLoadOlderState.Unavailable; + // Locally retained older entries are shown by growing the display window (no fetch); only when + // none remain does the server-side bounded page path apply. + var hasMoreHistory = hiddenEarlierEntries > 0 || loadOlderState is + ChatLoadOlderState.Available or ChatLoadOlderState.Loading or ChatLoadOlderState.Error; + Action? onLoadMoreHistory = !hasMoreHistory || effectiveThread is not { } olderThread + ? null + : hiddenEarlierEntries > 0 + ? () => MoveDisplayWindowOlder(olderThread.Id) + : props.Provider is OpenClawChatDataProvider olderProvider + ? () => _ = LoadOlderObservedAsync(olderProvider, olderThread.Id) + : null; + + // Actual newer / return-to-live control: after navigating earlier, retained newer entries are + // reachable again (moving one page, then returning to the live tail). + var canShowNewerHistory = hiddenLaterEntries > 0; + Action? onShowNewerHistory = !canShowNewerHistory || effectiveThread is not { } newerThread + ? null + : hiddenLaterEntries <= ChatTimelineDisplayWindowPolicy.NavigationPageSize + ? () => ReturnDisplayWindowToLive(newerThread.Id) // one click back to the live tail + : () => MoveDisplayWindowNewer(newerThread.Id); + var timelineProps = new ChatTimelinePresentationContext( effectiveThread?.Id, entries, - false, - null, + hasMoreHistory, + onLoadMoreHistory, entryMetadata, timelineGeneration, "OpenClaw Windows Tray", "Assistant", effectiveThread?.Model, + // Global latest qualifying usage ONLY. Never fall back to the windowed slice here, which would + // mislabel a window-local assistant with usage that belongs to a newer global entry. showToolCalls - ? ChatUsageFormatter.Format(entries, entryMetadata) ?? ChatUsageFormatter.Format(effectiveThread) + ? usageSummary ?? ChatUsageFormatter.Format(effectiveThread) : null, showThinking, showToolCalls, @@ -264,10 +474,52 @@ effectiveThread is { } permissionThread : null, mediaResolver, queuedMessages, - props.ComposerSession.Controller.CancelQueuedMessage); + props.ComposerSession.Controller.CancelQueuedMessage, + loadOlderState, + onLoadMoreHistory, + canShowNewerHistory, + onShowNewerHistory, + latestUsage.EntryId, + toolActivityWindow, + toolActivityPending, + displayWindow.Pending); // WindowPending: consumer shows loading/holding, not a fake resolved jump + + // Observed action: the provider records a generic retryable Error state internally, so a + // transport/protocol fault never leaves an unobserved task or raw exception text on the UI. + static async Task LoadOlderObservedAsync(OpenClawChatDataProvider provider, string threadId) + { + try + { + await provider.LoadOlderAsync(threadId).ConfigureAwait(false); + } + catch (Exception) + { + } + } + + void MoveDisplayWindowOlder(string threadId) + { + // Move the bounded window one page toward older entries (fixed size; never grows to all). + _navigation.MoveOlder(threadId, timeline, ChatTimelineDisplayWindowPolicy.DefaultWindowSize); + setDisplayWindowVersion(displayWindowVersion + 1); // value setter (Reactor UseState), not a Func + } + + void MoveDisplayWindowNewer(string threadId) + { + _navigation.MoveNewer(threadId, timeline, ChatTimelineDisplayWindowPolicy.DefaultWindowSize); + setDisplayWindowVersion(displayWindowVersion + 1); + } + + void ReturnDisplayWindowToLive(string threadId) + { + _navigation.ReturnToLive(threadId, timeline, ChatTimelineDisplayWindowPolicy.DefaultWindowSize); + setDisplayWindowVersion(displayWindowVersion + 1); + } void SelectThread(string threadId) { + // Lifecycle pruning: a thread selection starts from the live tail (bounded nav state). + _navigation.Forget(threadId); _pendingSelectedThreadId = threadId; selectedIdRef.Current = threadId; setSelectedId(threadId); diff --git a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs index 7cffcc486c..b7116984e5 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs @@ -224,6 +224,11 @@ private static IReadOnlyList BuildRows(ReactorChatTimelinePr if (props.Mode == ReactorChatTimelineMode.Loading) return [ReactorTimelineRow.Loading(props)]; + // ACTUAL pending consumer FIRST: while a deep anchor is unresolved, render an explicit LOADING row so + // neither a fake resolved slice NOR the empty/welcome surface is shown. + if (props.Timeline.WindowPending) + return [ReactorTimelineRow.Loading(props)]; + if (props.Mode == ReactorChatTimelineMode.Empty) return [ReactorTimelineRow.Empty(props)]; @@ -232,7 +237,12 @@ private static IReadOnlyList BuildRows(ReactorChatTimelinePr rows.Add(ReactorTimelineRow.LoadEarlier(props)); var chronologicalEntries = props.Timeline.Entries; - var assistantRunPositions = ChatTimelineAssistantRuns.Describe(chronologicalEntries); + var toolActivityWindow = + props.Timeline.ToolActivityWindow ?? ChatToolActivityPresentation.ChatToolActivityWindowContext.None; + var assistantRunPositions = ChatTimelineAssistantRuns.Describe( + chronologicalEntries, + toolActivityWindow.LeadingAssistantRunContinuesBefore, + toolActivityWindow.LeadingAssistantRunContinuesAfter); var assistantRunsByEntryId = new Dictionary( chronologicalEntries.Count, StringComparer.Ordinal); @@ -241,12 +251,19 @@ private static IReadOnlyList BuildRows(ReactorChatTimelinePr var latestAssistantEntryId = chronologicalEntries .LastOrDefault(static entry => entry.Kind == ChatTimelineItemKind.Assistant) ?.Id; + // Only the GLOBAL latest qualifying assistant may carry the full-retained usage summary; an earlier + // window's slice-local last assistant keeps its own row metadata (no newer-usage mislabel). + var usageAttributionEntryId = ChatTimelineUsageAttribution.Resolve( + props.Timeline.LatestUsageEntryId, + latestAssistantEntryId); var projectedRows = ChatToolActivityPresentation.Project( chronologicalEntries, props.Timeline.SessionId, props.Timeline.TimelineGeneration, - props.Timeline.ShowToolCalls); + props.Timeline.ShowToolCalls, + toolActivityWindow, + props.Timeline.ToolActivityPending); foreach (var projectedRow in projectedRows) { if (projectedRow.IsActivityGroup) @@ -259,7 +276,7 @@ private static IReadOnlyList BuildRows(ReactorChatTimelinePr rows.Add(ReactorTimelineRow.FromEntry( props, entry, - string.Equals(entry.Id, latestAssistantEntryId, StringComparison.Ordinal), + ChatTimelineUsageAttribution.IsGlobalLatestAssistant(entry.Id, usageAttributionEntryId), assistantRunsByEntryId.TryGetValue(entry.Id, out var position) ? position : default)); } @@ -272,6 +289,10 @@ private static IReadOnlyList BuildRows(ReactorChatTimelinePr rows.Add(ReactorTimelineRow.FromQueuedMessage(props, message)); } + // After navigating earlier, the retained newer/live entries remain reachable. + if (props.Timeline.CanShowNewerHistory) + rows.Add(ReactorTimelineRow.ShowNewer(props)); + return rows; } @@ -287,6 +308,7 @@ private static Element BuildRow( ReactorTimelineRowKind.Loading => BuildLoading(row), ReactorTimelineRowKind.Empty => BuildEmpty(row), ReactorTimelineRowKind.LoadEarlier => BuildLoadEarlier(row), + ReactorTimelineRowKind.ShowNewer => BuildShowNewer(row), ReactorTimelineRowKind.Thinking => BuildThinking(row), ReactorTimelineRowKind.Activity when row.Activity is { } activity => ToolCallCardRenderer.BuildActivity( @@ -380,8 +402,46 @@ private static Element BuildEmpty(ReactorTimelineRow row) private static Element BuildLoadEarlier(ReactorTimelineRow row) { - var label = LocalizedOrDefault("Chat_Timeline_LoadEarlier", "Load earlier messages"); - return Button(label, () => row.Props.Timeline.OnLoadMoreHistory?.Invoke()) + // Consumes the explicit load-earlier state: loading shows a progress label, a generic + // retryable error offers Retry, and the available case keeps the existing load-earlier button. + switch (row.Props.Timeline.LoadOlderState) + { + case ChatLoadOlderState.Loading: + { + var loading = LocalizedOrDefault( + "Chat_Timeline_LoadingEarlier", "Loading earlier messages…"); + return Text(loading, 12) + .Margin(0, 8) + .HAlign(HorizontalAlignment.Center) + .AutomationName(loading); + } + case ChatLoadOlderState.Error: + { + var retry = LocalizedOrDefault( + "Chat_Timeline_RetryLoadEarlier", "Couldn't load earlier messages. Retry"); + return Button( + retry, + () => (row.Props.Timeline.OnRetryLoadOlder + ?? row.Props.Timeline.OnLoadMoreHistory)?.Invoke()) + .Margin(0, 8) + .HAlign(HorizontalAlignment.Center) + .AutomationName(retry); + } + default: + { + var label = LocalizedOrDefault("Chat_Timeline_LoadEarlier", "Load earlier messages"); + return Button(label, () => row.Props.Timeline.OnLoadMoreHistory?.Invoke()) + .Margin(0, 8) + .HAlign(HorizontalAlignment.Center) + .AutomationName(label); + } + } + } + + private static Element BuildShowNewer(ReactorTimelineRow row) + { + var label = LocalizedOrDefault("Chat_Timeline_ShowNewer", "Newer messages"); + return Button(label, () => row.Props.Timeline.OnShowNewerHistory?.Invoke()) .Margin(0, 8) .HAlign(HorizontalAlignment.Center) .AutomationName(label); @@ -1227,7 +1287,10 @@ internal sealed record ReactorTimelineRow( bool IsLatestAssistant = false, bool IsAssistantRunStart = false, bool IsAssistantRunEnd = false, - ChatQueuedMessage? QueuedMessage = null) + ChatQueuedMessage? QueuedMessage = null, + // True while the bounded source-fact context for this row is PROVISIONAL (async completion pending): the + // ACTUAL consumer reads this to hold expansion/continuation state instead of remounting the group. + bool Pending = false) { public static ReactorTimelineRow FromQueuedMessage( ReactorChatTimelineProps props, @@ -1262,7 +1325,8 @@ public static ReactorTimelineRow FromActivity( ReactorTimelineRowKind.Activity, props, null, - activity); + activity, + Pending: activity.Pending); public static ReactorTimelineRow Thinking(ReactorChatTimelineProps props) => new( @@ -1284,6 +1348,16 @@ public static ReactorTimelineRow LoadEarlier(ReactorChatTimelineProps props) => props, null); + public static ReactorTimelineRow ShowNewer(ReactorChatTimelineProps props) => + new( + ReactorChatTimeline.SyntheticRowKey( + props.Timeline, + "__show-newer__", + ChatTimelineItemKind.Status), + ReactorTimelineRowKind.ShowNewer, + props, + null); + public static ReactorTimelineRow Loading(ReactorChatTimelineProps props) => new("timeline:loading", ReactorTimelineRowKind.Loading, props, null); @@ -1298,6 +1372,7 @@ internal enum ReactorTimelineRowKind Activity, Thinking, LoadEarlier, + ShowNewer, Loading, Empty, } @@ -1307,19 +1382,16 @@ internal enum ReactorTimelineRowKind internal static class ChatTimelineAssistantRuns { public static IReadOnlyList Describe( - IReadOnlyList entries) + IReadOnlyList entries, + bool leadingRunContinuesBefore = false, + bool trailingRunContinuesAfter = false) { - var positions = new ChatAssistantRunPosition[entries.Count]; - for (var index = 0; index < entries.Count; index++) - { - if (entries[index].Kind != ChatTimelineItemKind.Assistant) - continue; - - var isStart = index == 0 || entries[index - 1].Kind != ChatTimelineItemKind.Assistant; - var isEnd = index == entries.Count - 1 || entries[index + 1].Kind != ChatTimelineItemKind.Assistant; - positions[index] = new ChatAssistantRunPosition(isStart, isEnd); - } - + // The ACTUAL consumer uses the SAME shared source policy the portable tests exercise. + var spans = ChatToolActivityPresentation.ChatAssistantRunPolicy.Describe( + entries, leadingRunContinuesBefore, trailingRunContinuesAfter); + var positions = new ChatAssistantRunPosition[spans.Count]; + for (var index = 0; index < spans.Count; index++) + positions[index] = new ChatAssistantRunPosition(spans[index].IsStart, spans[index].IsEnd); return positions; } } diff --git a/src/OpenClaw.Tray.WinUI/Diagnostics/CompanionHangCapture.cs b/src/OpenClaw.Tray.WinUI/Diagnostics/CompanionHangCapture.cs new file mode 100644 index 0000000000..000558a59c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Diagnostics/CompanionHangCapture.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace OpenClaw.Tray.WinUI.Diagnostics; + +/// +/// U4 companion reliability: writes a local, credential-free hang report so a freeze +/// leaves evidence that can be correlated with companion and gateway logs. Bounded +/// collection, trigger sanitization and exclusive file creation live in the shared +/// helper so they are unit-testable and +/// identical across consumers. This never restarts the gateway, the companion or any +/// migration worker and never reads configuration or credentials. +/// +internal static class CompanionHangCapture +{ + public static string Write(string directory, string trigger, IEnumerable frames, DateTimeOffset nowUtc) + { + var bounded = OpenClaw.Shared.HangCaptureReport.CollectFrames(frames); + var text = OpenClaw.Shared.HangCaptureReport.Format(trigger, nowUtc, bounded); + return OpenClaw.Shared.HangCaptureReport.WriteExclusive(directory, text, nowUtc); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml index c32267f1a9..2f73b0ca0f 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml @@ -73,6 +73,16 @@ + + +