fix(claude-code): a late expiry after a completed login replays without a second login

This commit is contained in:
EugeneTes
2026-09-25 17:52:21 +00:00
parent 7ed521f6a6
commit fb7be219e5
4 changed files with 78 additions and 7 deletions

View File

@@ -87,17 +87,24 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
try
{
var loginUi = Options.LoginUi;
var coordinator = Options.Coordinator ?? ClaudeLoginCoordinator.Shared;
var generation = coordinator.Generation;
var expired = await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: loginUi is not null, ct).ConfigureAwait(false);
if (expired is null || loginUi is null) return;
// The session expired before anything was published: pause, let the owner log in, then replay exactly once.
// A login that succeeded after this attempt started makes the expiry stale: replay without another flow.
if (coordinator.Generation != generation)
{
await RunAttemptAsync(model, context, streamOptions, writer, deferLoginFailure: false, ct).ConfigureAwait(false);
return;
}
var builder = new AssistantMessageBuilder(model);
Notify(streamOptions.SessionId, "Claude Code session expired; waiting for the bot owner to sign in…", NotifyKind.Warning);
LoginOutcome outcome;
try
{
outcome = await (Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
.EnsureLoggedInAsync(flowCt => RunLoginFlowAsync(loginUi, flowCt), ct).ConfigureAwait(false);
outcome = await coordinator.EnsureLoggedInAsync(flowCt => RunLoginFlowAsync(loginUi, flowCt), ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{

View File

@@ -3,7 +3,9 @@ namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>
/// Keeps Claude Code logins single-flight: every session shares one <c>claude</c> configuration, so concurrent expired
/// requests wait on the same flow. The flow runs detached from any caller's token; a caller's cancellation only stops
/// its own wait. The slot clears when the flow completes, so a later expiry starts a fresh one.
/// its own wait. The slot clears when the flow completes, so a later expiry starts a fresh one. Each successful flow
/// advances <see cref="Generation"/>, so a request whose attempt started before a login finished can tell that its
/// late expiry is stale and replay without starting another flow.
/// </summary>
public sealed class ClaudeLoginCoordinator
{
@@ -12,6 +14,10 @@ public sealed class ClaudeLoginCoordinator
private readonly Lock _lock = new();
private Task<LoginOutcome>? _current;
private long _generation;
/// <summary>How many flows have succeeded so far; read it before an attempt, compare after a login-required failure.</summary>
public long Generation => Interlocked.Read(ref _generation);
/// <summary>Joins the in-flight login, or starts one. <paramref name="runFlow"/> only ever runs for the caller that starts it.</summary>
public Task<LoginOutcome> EnsureLoggedInAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow, CancellationToken cancellationToken)
@@ -33,17 +39,21 @@ public sealed class ClaudeLoginCoordinator
// overwrite it with the now-finished task. The slot would then stay stuck on a stale result forever, since
// nothing else ever clears it. Task.Yield() guarantees the finally always runs on a later, separate turn.
await Task.Yield();
LoginOutcome outcome;
try
{
return await runFlow(CancellationToken.None).ConfigureAwait(false);
outcome = await runFlow(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
return LoginOutcome.Failed(ex.Message);
outcome = LoginOutcome.Failed(ex.Message);
}
finally
lock (_lock)
{
lock (_lock) _current = null;
// Advanced before the slot clears: a caller that finds the slot empty already sees the new generation.
if (outcome.Success) Interlocked.Increment(ref _generation);
_current = null;
}
return outcome;
}
}