Skip to content

Commit 4aa3442

Browse files
committed
Fix agent shell startup and chat flows
1 parent 3d06f1a commit 4aa3442

39 files changed

Lines changed: 1490 additions & 224 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ For this app:
186186
- When agent conversations must survive restarts, persist the full `AgentSession` plus chat history through an Agent Framework history/storage provider backed by a local desktop folder; do not reduce durable conversation state to transcript text rows only
187187
- Do not add cache layers for provider CLIs, model catalogs, workspace projections, or similar environment state unless the user explicitly asks for caching; prefer direct reads from the current source of truth
188188
- Current repository policy is stricter than the default: do not keep provider-status caches, workspace/session in-memory mirrors, or app-preference caches at all unless the user explicitly asks to bring a specific cache back
189+
- The current explicit exception is startup readiness hydration: the app may show a splash/loading state at launch, probe installed provider CLIs and related metadata once during that startup window, and then reuse that startup snapshot until an explicit refresh or provider-setting change invalidates it
189190
- Provider CLI probing must not rerun as a side effect of ordinary screen binding or MVUX state re-evaluation; normal shell loads should share one in-flight probe and only reprobe on explicit refresh or provider-setting changes
190191
- Expected cancellation from state re-evaluation or navigation must not be logged as a product failure; reserve failure logs for real errors, not superseded async loads
191192
- Runtime and orchestration flows must emit structured `ILogger` logs for provider readiness, agent creation, session creation, send execution, and failure paths; ad hoc console-only startup traces are not enough to debug the product
@@ -391,6 +392,7 @@ Ask first:
391392
- Follow the canonical MCAF tutorial when bootstrapping or upgrading the agent workflow.
392393
- Commit cohesive code-change batches promptly while debugging, especially before switching focus or starting long verification runs, so the branch state stays inspectable and pushable.
393394
- After opening or updating a PR, create a fresh working branch before continuing with the next slice of work so follow-up changes do not pile onto the already-reviewed branch.
395+
- When one requested slice is complete and verified, commit it before switching to the next GitHub issue so each backlog step stays isolated and reviewable.
394396
- Keep `DotPilot` feeling like a fast desktop control plane: startup, navigation, and visible UI reactions should be prompt, and agents should remove unnecessary waits instead of normalizing slow web-style loading behavior.
395397
- Keep the root `AGENTS.md` at the repository root.
396398
- Keep the repo-local agent skill directory limited to current `mcaf-*` skills.

DotPilot.Core/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Stack: `.NET 10`, class library, non-UI contracts, orchestration, persistence, a
4242
- keep this structure SOLID at the folder and project level too: cohesive feature slices stay together, but once a slice becomes too large or too independent, it should graduate into its own project instead of turning `DotPilot.Core` into mud
4343
- Keep provider-independent testing seams real and deterministic so CI can validate core flows without external CLIs.
4444
- Keep provider readiness probing explicit and coalesced: ordinary workspace reads may share one in-flight CLI probe, but normal navigation must not fan out into repeated PATH/version probing loops.
45+
- The approved caching exception in this project is startup readiness hydration: Core may keep one startup-owned provider/CLI snapshot after the initial splash-time probe, but it must invalidate that snapshot on explicit refresh or provider preference changes instead of drifting into a long-lived opaque cache layer.
4546
- Treat superseded async loads as cancellation, not failure; Core services should not emit error-level noise for expected state invalidation or navigation churn.
4647

4748
## Local Commands

DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public static IServiceCollection AddAgentSessions(
2424
services.AddSingleton<AgentRuntimeConversationFactory>();
2525
services.AddSingleton<IAgentSessionService, AgentSessionService>();
2626
services.AddSingleton<IAgentWorkspaceState, AgentWorkspaceState>();
27+
services.AddSingleton<IStartupWorkspaceHydration, StartupWorkspaceHydration>();
2728
return services;
2829
}
2930

DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,24 @@ public static partial void SendCompleted(
268268
Message = "Session send failed. SessionId={SessionId} AgentId={AgentId}.")]
269269
public static partial void SendFailed(ILogger logger, Exception exception, SessionId sessionId, Guid agentId);
270270
}
271+
272+
internal static partial class StartupWorkspaceHydrationLog
273+
{
274+
[LoggerMessage(
275+
EventId = 1300,
276+
Level = LogLevel.Information,
277+
Message = "Starting startup workspace hydration.")]
278+
public static partial void HydrationStarted(ILogger logger);
279+
280+
[LoggerMessage(
281+
EventId = 1301,
282+
Level = LogLevel.Information,
283+
Message = "Startup workspace hydration completed.")]
284+
public static partial void HydrationCompleted(ILogger logger);
285+
286+
[LoggerMessage(
287+
EventId = 1302,
288+
Level = LogLevel.Error,
289+
Message = "Startup workspace hydration failed.")]
290+
public static partial void HydrationFailed(ILogger logger, Exception exception);
291+
}

DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ private async ValueTask<Result<AgentWorkspaceSnapshot>> LoadWorkspaceAsync(
7979
providers,
8080
sessionItems.Length > 0 ? sessionItems[0].Id : null));
8181
}
82+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
83+
{
84+
throw;
85+
}
8286
catch (Exception exception)
8387
{
8488
AgentSessionServiceLog.WorkspaceLoadFailed(logger, exception);
@@ -124,6 +128,10 @@ public async ValueTask<Result<SessionTranscriptSnapshot>> GetSessionAsync(Sessio
124128

125129
return Result<SessionTranscriptSnapshot>.Succeed(snapshot);
126130
}
131+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
132+
{
133+
throw;
134+
}
127135
catch (Exception exception)
128136
{
129137
AgentSessionServiceLog.SessionLoadFailed(logger, exception, sessionId);
@@ -791,12 +799,14 @@ private async ValueTask<IReadOnlyList<ProviderStatusDescriptor>> GetProviderStat
791799
bool forceRefresh,
792800
CancellationToken cancellationToken)
793801
{
794-
_ = forceRefresh;
795-
return await providerStatusReader.ReadAsync(cancellationToken);
802+
return forceRefresh
803+
? await providerStatusReader.RefreshAsync(cancellationToken)
804+
: await providerStatusReader.ReadAsync(cancellationToken);
796805
}
797806

798-
private static void InvalidateProviderStatusSnapshot()
807+
private void InvalidateProviderStatusSnapshot()
799808
{
809+
providerStatusReader.Invalidate();
800810
}
801811

802812
private async Task NormalizeLegacyAgentProfilesAsync(

DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,8 @@ namespace DotPilot.Core.Providers.Interfaces;
33
public interface IAgentProviderStatusReader
44
{
55
ValueTask<IReadOnlyList<ProviderStatusDescriptor>> ReadAsync(CancellationToken cancellationToken);
6+
7+
ValueTask<IReadOnlyList<ProviderStatusDescriptor>> RefreshAsync(CancellationToken cancellationToken);
8+
9+
void Invalidate();
610
}

DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@ internal sealed class AgentProviderStatusReader(
1212
{
1313
private const string MissingValue = "<none>";
1414
private readonly object activeReadSync = new();
15+
private IReadOnlyList<ProviderStatusDescriptor>? cachedSnapshot;
1516
private Task<IReadOnlyList<ProviderStatusDescriptor>>? activeReadTask;
17+
private long activeReadGeneration = -1;
18+
private long snapshotGeneration;
1619

1720
public async ValueTask<IReadOnlyList<ProviderStatusDescriptor>> ReadAsync(CancellationToken cancellationToken)
1821
{
1922
try
2023
{
21-
var readTask = GetOrStartActiveRead();
24+
var readTask = GetOrStartActiveRead(forceRefresh: false);
2225
return await readTask.WaitAsync(cancellationToken);
2326
}
2427
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -30,33 +33,100 @@ public async ValueTask<IReadOnlyList<ProviderStatusDescriptor>> ReadAsync(Cancel
3033
AgentProviderStatusReaderLog.ReadFailed(logger, exception);
3134
throw;
3235
}
33-
finally
36+
}
37+
38+
public async ValueTask<IReadOnlyList<ProviderStatusDescriptor>> RefreshAsync(CancellationToken cancellationToken)
39+
{
40+
try
3441
{
35-
ClearCompletedActiveRead();
42+
var readTask = GetOrStartActiveRead(forceRefresh: true);
43+
return await readTask.WaitAsync(cancellationToken);
44+
}
45+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
46+
{
47+
throw;
48+
}
49+
catch (Exception exception)
50+
{
51+
AgentProviderStatusReaderLog.ReadFailed(logger, exception);
52+
throw;
3653
}
3754
}
3855

39-
private Task<IReadOnlyList<ProviderStatusDescriptor>> GetOrStartActiveRead()
56+
public void Invalidate()
4057
{
4158
lock (activeReadSync)
4259
{
43-
if (activeReadTask is { IsCompleted: false })
60+
snapshotGeneration++;
61+
cachedSnapshot = null;
62+
}
63+
}
64+
65+
private Task<IReadOnlyList<ProviderStatusDescriptor>> GetOrStartActiveRead(bool forceRefresh)
66+
{
67+
Task<IReadOnlyList<ProviderStatusDescriptor>>? activeTask;
68+
long generation;
69+
70+
lock (activeReadSync)
71+
{
72+
if (!forceRefresh && cachedSnapshot is { } snapshot)
73+
{
74+
return Task.FromResult(snapshot);
75+
}
76+
77+
if (!forceRefresh &&
78+
activeReadTask is { IsCompleted: false } &&
79+
activeReadGeneration == snapshotGeneration)
4480
{
4581
return activeReadTask;
4682
}
4783

48-
activeReadTask = ReadFromCurrentSourcesAsync();
49-
return activeReadTask;
84+
if (forceRefresh)
85+
{
86+
snapshotGeneration++;
87+
cachedSnapshot = null;
88+
}
89+
90+
generation = snapshotGeneration;
91+
activeReadGeneration = generation;
92+
activeReadTask = CreateReadTask(generation);
93+
activeTask = activeReadTask;
5094
}
95+
96+
return activeTask;
5197
}
5298

53-
private void ClearCompletedActiveRead()
99+
private Task<IReadOnlyList<ProviderStatusDescriptor>> CreateReadTask(long generation)
54100
{
55-
lock (activeReadSync)
101+
Task<IReadOnlyList<ProviderStatusDescriptor>>? readTask = null;
102+
readTask = ReadAndCacheAsync();
103+
return readTask;
104+
105+
async Task<IReadOnlyList<ProviderStatusDescriptor>> ReadAndCacheAsync()
56106
{
57-
if (activeReadTask is { IsCompleted: true })
107+
try
108+
{
109+
var snapshot = await ReadFromCurrentSourcesAsync();
110+
lock (activeReadSync)
111+
{
112+
if (generation == snapshotGeneration)
113+
{
114+
cachedSnapshot = snapshot;
115+
}
116+
}
117+
118+
return snapshot;
119+
}
120+
finally
58121
{
59-
activeReadTask = null;
122+
lock (activeReadSync)
123+
{
124+
if (ReferenceEquals(activeReadTask, readTask))
125+
{
126+
activeReadTask = null;
127+
activeReadGeneration = -1;
128+
}
129+
}
60130
}
61131
}
62132
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using Microsoft.Extensions.Logging;
2+
3+
namespace DotPilot.Core.Workspace;
4+
5+
internal static partial class StartupWorkspaceHydrationLog
6+
{
7+
[LoggerMessage(
8+
EventId = 1500,
9+
Level = LogLevel.Information,
10+
Message = "Starting startup workspace hydration.")]
11+
public static partial void HydrationStarted(ILogger logger);
12+
13+
[LoggerMessage(
14+
EventId = 1501,
15+
Level = LogLevel.Information,
16+
Message = "Completed startup workspace hydration.")]
17+
public static partial void HydrationCompleted(ILogger logger);
18+
19+
[LoggerMessage(
20+
EventId = 1502,
21+
Level = LogLevel.Error,
22+
Message = "Startup workspace hydration failed.")]
23+
public static partial void HydrationFailed(ILogger logger, Exception exception);
24+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace DotPilot.Core.Workspace.Interfaces;
2+
3+
public interface IStartupWorkspaceHydration
4+
{
5+
bool IsHydrating { get; }
6+
7+
bool IsReady { get; }
8+
9+
event EventHandler? StateChanged;
10+
11+
ValueTask EnsureHydratedAsync(CancellationToken cancellationToken);
12+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using Microsoft.Extensions.Logging;
2+
3+
namespace DotPilot.Core.Workspace;
4+
5+
internal sealed class StartupWorkspaceHydration(
6+
IAgentWorkspaceState workspaceState,
7+
ILogger<StartupWorkspaceHydration> logger)
8+
: IStartupWorkspaceHydration, IDisposable
9+
{
10+
private readonly SemaphoreSlim hydrationGate = new(1, 1);
11+
private readonly object stateSync = new();
12+
private bool isHydrating;
13+
private bool isReady;
14+
15+
public bool IsHydrating
16+
{
17+
get
18+
{
19+
lock (stateSync)
20+
{
21+
return isHydrating;
22+
}
23+
}
24+
}
25+
26+
public bool IsReady
27+
{
28+
get
29+
{
30+
lock (stateSync)
31+
{
32+
return isReady;
33+
}
34+
}
35+
}
36+
37+
public event EventHandler? StateChanged;
38+
39+
public void Dispose()
40+
{
41+
hydrationGate.Dispose();
42+
}
43+
44+
public async ValueTask EnsureHydratedAsync(CancellationToken cancellationToken)
45+
{
46+
if (IsReady)
47+
{
48+
return;
49+
}
50+
51+
await hydrationGate.WaitAsync(cancellationToken);
52+
try
53+
{
54+
if (IsReady)
55+
{
56+
return;
57+
}
58+
59+
UpdateState(isHydrating: true, isReady: false);
60+
StartupWorkspaceHydrationLog.HydrationStarted(logger);
61+
62+
try
63+
{
64+
var workspace = await workspaceState.GetWorkspaceAsync(cancellationToken);
65+
if (workspace.IsFailed)
66+
{
67+
StartupWorkspaceHydrationLog.HydrationFailed(
68+
logger,
69+
new InvalidOperationException("Startup workspace hydration failed."));
70+
}
71+
}
72+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
73+
{
74+
throw;
75+
}
76+
catch (Exception exception)
77+
{
78+
StartupWorkspaceHydrationLog.HydrationFailed(logger, exception);
79+
}
80+
finally
81+
{
82+
UpdateState(isHydrating: false, isReady: true);
83+
StartupWorkspaceHydrationLog.HydrationCompleted(logger);
84+
}
85+
}
86+
finally
87+
{
88+
hydrationGate.Release();
89+
}
90+
}
91+
92+
private void UpdateState(bool isHydrating, bool isReady)
93+
{
94+
var changed = false;
95+
lock (stateSync)
96+
{
97+
if (this.isHydrating != isHydrating)
98+
{
99+
this.isHydrating = isHydrating;
100+
changed = true;
101+
}
102+
103+
if (this.isReady != isReady)
104+
{
105+
this.isReady = isReady;
106+
changed = true;
107+
}
108+
}
109+
110+
if (changed)
111+
{
112+
StateChanged?.Invoke(this, EventArgs.Empty);
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)