Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/OpenClaw.Chat/ChatModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,10 @@ public record ChatDataSnapshot(
bool CommandsSupported = true,
IReadOnlyDictionary<string, long>? TimelineGenerations = null,
IReadOnlyDictionary<string, long>? HistoryRevisions = null,
IReadOnlyDictionary<string, IReadOnlyList<ChatQueuedMessage>>? QueuedMessagesByThread = null);
IReadOnlyDictionary<string, IReadOnlyList<ChatQueuedMessage>>? 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<string, string>? AcceptedSessionIds = null);

/// <summary>
/// Describes where the UI may send the next chat message. Distinct from
Expand Down
28 changes: 27 additions & 1 deletion src/OpenClaw.Chat/ChatTimelineReducer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,37 @@ public static class ChatTimelineReducer
private const int MaxTerminalToolCorrelations = 768;
private const int MaxRetainedToolTurns = 2;

public static ChatTimelineState RebuildActiveToolTracking(ChatTimelineState state)
/// <summary>
/// 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).
/// </summary>
public static ChatTimelineState RebuildActiveToolTracking(
ChatTimelineState state,
CancellationToken cancellationToken = default,
Action<long>? operationObserver = null)
{
cancellationToken.ThrowIfCancellationRequested(); // stage entry
var activeToolCallId = default(string);
var activeToolCalls =
System.Collections.Immutable.ImmutableDictionary<ChatToolCorrelationKey, string>.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))
{
Expand All @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/OpenClaw.Connection/LocalAi/LocalAiApiCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
}
}
}
}
28 changes: 28 additions & 0 deletions src/OpenClaw.HangCapture/OpenClaw.HangCapture.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>OpenClaw.HangCapture</AssemblyName>
<RootNamespace>OpenClawTray.Services</RootNamespace>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\OpenClaw.Shared\OpenClaw.Shared.csproj" />
</ItemGroup>

<ItemGroup>
<Compile Include="..\OpenClaw.Tray.WinUI\Services\UiThreadStackCollector.cs" Link="Services\UiThreadStackCollector.cs" />
<Compile Include="..\OpenClaw.Tray.WinUI\Services\CompanionHangCapture.cs" Link="Services\CompanionHangCapture.cs" />
<Compile Include="..\OpenClaw.Tray.WinUI\Services\HangCaptureHost.cs" Link="Services\HangCaptureHost.cs" />
<Compile Include="..\OpenClaw.Tray.WinUI\Services\CaptureDrain.cs" Link="Services\CaptureDrain.cs" />
<Compile Include="..\OpenClaw.Tray.WinUI\Services\CaptureResumeObligations.cs" Link="Services\CaptureResumeObligations.cs" />
<Compile Include="..\OpenClaw.Tray.WinUI\Services\CaptureHostLifetime.cs" Link="Services\CaptureHostLifetime.cs" />
<Compile Include="..\OpenClaw.Tray.WinUI\Services\WindowsUiThreadStackSource.cs" Link="Services\WindowsUiThreadStackSource.cs" />
<Compile Include="..\OpenClaw.Tray.WinUI\Services\DiagnosticsExportSanitizer.cs" Link="Services\DiagnosticsExportSanitizer.cs" />
</ItemGroup>

</Project>
11 changes: 11 additions & 0 deletions src/OpenClaw.HangCapture/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/OpenClaw.Shared/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("OpenClaw.Shared.Tests")]
[assembly: InternalsVisibleTo("OpenClaw.Tray.WinUI")]
[assembly: InternalsVisibleTo("OpenClaw.Tray.Tests")]
166 changes: 166 additions & 0 deletions src/OpenClaw.Shared/HangCaptureReport.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <see cref="DefaultMaxFrames"/> 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.
/// </summary>
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<string> 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<string>? frames)
=> frames is null ? 0 : frames.Count(f => IsSensitiveLine(f));

/// <summary>Maps any caller-supplied reason to a known safe token.</summary>
public static string NormalizeTrigger(string? trigger)
{
if (string.IsNullOrWhiteSpace(trigger)) return UnknownTrigger;
return KnownTriggers.Contains(trigger.Trim()) ? trigger.Trim() : UnknownTrigger;
}

/// <summary>
/// Enumerates at most <paramref name="maxFrames"/> 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.
/// </summary>
public static List<string> CollectFrames(
IEnumerable<string>? frames,
int maxFrames = DefaultMaxFrames,
int maxCharsPerFrame = DefaultMaxCharsPerFrame,
int maxTotalChars = DefaultMaxTotalFrameChars)
{
var result = new List<string>();
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<string> 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();
}

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// Creates <paramref name="path"/> 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.
/// </summary>
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;
}
}
28 changes: 28 additions & 0 deletions src/OpenClaw.Shared/HistoryPagingBudget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;

namespace OpenClaw.Shared;

/// <summary>
/// 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.
/// </summary>
internal static class HistoryPagingBudget
{
public const int DefaultPageSize = 200;
public const int DefaultItemsPerTick = 250;

/// <summary>Rows to request for the next page (0 when nothing remains).</summary>
public static int NextPageSize(int remaining, int pageSize = DefaultPageSize)
=> remaining <= 0 || pageSize <= 0 ? 0 : Math.Min(remaining, pageSize);

/// <summary>Rows that may be folded during this UI tick (0 when nothing pending).</summary>
public static int ItemsThisTick(int pending, int budget = DefaultItemsPerTick)
=> pending <= 0 || budget <= 0 ? 0 : Math.Min(pending, budget);

/// <summary>True when a full page came back, so paging should continue.</summary>
public static bool ShouldContinuePaging(int fetched, int pageSize)
=> fetched > 0 && pageSize > 0 && fetched >= pageSize;
}
4 changes: 4 additions & 0 deletions src/OpenClaw.Shared/IFileSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>Length in bytes, or -1 when the implementation cannot report it (bounded-read guard).</summary>
long GetFileLength(string path) => -1;
}

/// <summary>
Expand All @@ -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;
}
18 changes: 18 additions & 0 deletions src/OpenClaw.Shared/IOperatorGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ event EventHandler? SelfProfileChanged { add { } remove { } }
string? AuthenticatedSigningDeviceId => null;
IReadOnlyList<string> GrantedOperatorScopes { get; }
bool IsConnectedToGateway { get; }
/// <summary>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.</summary>
bool SessionCatalogRetryRequired => false;

/// <summary>True while a bounded catalog acquisition is in progress. Default false.</summary>
bool SessionCatalogAcquisitionInProgress => false;

/// <summary>Raised after an accepted catalog-refresh transition (retry needed or cleared).
/// Default no-op so non-catalog implementers stay source-compatible.</summary>
event EventHandler<SessionCatalogRefreshStateChangedEventArgs>? SessionCatalogRefreshStateChanged { add { } remove { } }
/// <summary>Canonical main session key resolved from hello-ok; <c>null</c> until handshake.</summary>
string? MainSessionKey { get; }
/// <summary>True once the hello-ok handshake has been processed.</summary>
Expand Down Expand Up @@ -73,6 +83,14 @@ event EventHandler? SelfProfileChanged { add { } remove { } }
/// </summary>
Task<ChatHistoryInfo> RequestChatHistoryAsync(string? sessionKey = null, int timeoutMs = 15000)
=> Task.FromException<ChatHistoryInfo>(new NotSupportedException("chat.history is not supported by this gateway client."));

/// <summary>Bounded chat.history page (source-grounded offset/limit/maxBytes). Default: unsupported.</summary>
Task<GatewayChatHistoryPage> RequestChatHistoryPageAsync(
string? sessionKey,
ChatHistoryPageOptions options,
int timeoutMs = 15000,
System.Threading.CancellationToken cancellationToken = default)
=> Task.FromException<GatewayChatHistoryPage>(new NotSupportedException("chat.history paging is not supported by this gateway client."));
Task CheckHealthAsync();
Task RequestSessionsAsync(string? agentId = null);
Task RequestUsageAsync();
Expand Down
Loading
Loading