Skip to content
Merged
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
12 changes: 8 additions & 4 deletions .specs/cloud-agent-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,19 @@ repository.
commands.
3. Preparation that only acquired and booted an environment -- warm reuse, no
real provisioning -- MUST NOT leave a completed preparation row.
4. Running and failed preparation MUST always be visible. Failed preparation
MUST show an error and a way to open details.
4. Running and failed preparation MUST always be visible. Failed preparation,
its triggering failed message, and its safe error MUST remain visible, with
a way to open details.
5. Setup commands MUST run on the first prepare and again on every rebuild. A
failing or timed-out setup command MUST fail preparation and the turn.
6. Follow-up turns MUST NOT show preparation unless the environment was
rebuilt.
7. Preparation output MUST NOT reveal tokens or secret values.
8. The composer MUST stay disabled until the environment is ready, and MUST say
which state it is waiting on.
8. The composer MUST stay disabled while preparation or finalization runs, and
MUST say which state it is waiting on. After preparation failure settles the
turn, the composer MUST be restored when the session is writable and its
transport permits sending. A later submission MUST use fresh message and
preparation-attempt identities.

### Turn

Expand Down
55 changes: 47 additions & 8 deletions packages/cloud-agent-sdk/src/base-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type Connection = {
disconnect: () => void;
reconnectWithRefreshedAuth?: () => void;
retryReconnect: () => void;
recoverAfterSuccessfulMutation: () => void;
destroy: () => void;
};

Expand All @@ -80,13 +81,16 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
let authRefreshAttempted = false;
let connected = false;
let reconnectAttempt = 0;
let exhausted = false;
let exhaustionReason: 'retry-limit' | 'auth-failure' | null = null;
let generation = 0;
let hasConnectedOnce = false;
let stalenessTimeoutId: ReturnType<typeof setTimeout> | null = null;
let lastMessageTime = 0;
let hiddenAt = 0;
let preconnectAuthRefreshAttempted = false;
// Coalesce successful mutations into one retry budget at a time. A later mutation may
// request another bounded cycle after that budget exhausts without any inbound event.
let mutationRecovery: 'idle' | 'pending' | 'active' = 'idle';
const stalenessTimeoutMs = config.stalenessTimeoutMs ?? DEFAULT_STALENESS_TIMEOUT_MS;
const maxReconnectAttempts = config.maxReconnectAttempts ?? MAX_RECONNECT_ATTEMPTS;

Expand All @@ -109,8 +113,8 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
}

function clearExhausted(): void {
if (exhausted) {
exhausted = false;
if (exhaustionReason !== null) {
exhaustionReason = null;
config.onReconnectExhaustionChange?.(false);
}
}
Expand Down Expand Up @@ -178,8 +182,14 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
if (destroyed || intentionalDisconnect || expectedGeneration !== generation) return;

if (attempt >= maxReconnectAttempts) {
if (!exhausted) {
exhausted = true;
if (mutationRecovery === 'pending') {
mutationRecovery = 'active';
retryReconnect();
return;
}
mutationRecovery = 'idle';
if (exhaustionReason === null) {
exhaustionReason = 'retry-limit';
config.onReconnectExhaustionChange?.(true);
}
return;
Expand Down Expand Up @@ -262,6 +272,7 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
// Reset auth refresh flag on successful message
authRefreshAttempted = false;
reconnectAttempt = 0;
mutationRecovery = 'idle';
clearExhausted();

if (!connected) {
Expand Down Expand Up @@ -313,9 +324,10 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
// Already tried refreshing auth and still failing - stop retrying.
// The current physical route is gone even though no new socket follows.
if (isAuthFailure && authRefreshAttempted) {
mutationRecovery = 'idle';
notifyReplacingConnection(expectedGeneration);
if (!exhausted) {
exhausted = true;
if (exhaustionReason === null) {
exhaustionReason = 'auth-failure';
config.onReconnectExhaustionChange?.(true);
}
return;
Expand Down Expand Up @@ -457,6 +469,7 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
destroyed = false;
authRefreshAttempted = false;
preconnectAuthRefreshAttempted = false;
mutationRecovery = 'idle';
connected = false;
reconnectAttempt = 0;
clearExhausted();
Expand All @@ -473,6 +486,7 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
intentionalDisconnect = true;
generation += 1;
preconnectAuthRefreshAttempted = false;
mutationRecovery = 'idle';

clearReconnectTimer();
clearStalenessTimeout();
Expand Down Expand Up @@ -519,10 +533,28 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
void refreshAndConnect(generation);
}

function recoverAfterSuccessfulMutation() {
if (
destroyed ||
intentionalDisconnect ||
connected ||
exhaustionReason === 'auth-failure' ||
mutationRecovery !== 'idle'
)
return;
if (exhaustionReason === 'retry-limit') {
mutationRecovery = 'active';
retryReconnect();
return;
}
mutationRecovery = 'pending';
}

function destroy() {
destroyed = true;
generation += 1;
preconnectAuthRefreshAttempted = false;
mutationRecovery = 'idle';

clearReconnectTimer();
clearStalenessTimeout();
Expand All @@ -537,7 +569,14 @@ export function createBaseConnection<T>(config: BaseConnectionConfig<T>): Connec
connected = false;
}

return { connect, disconnect, reconnectWithRefreshedAuth, retryReconnect, destroy };
return {
connect,
disconnect,
reconnectWithRefreshedAuth,
retryReconnect,
recoverAfterSuccessfulMutation,
destroy,
};
}

export function createBrowserLifecycleHooks(): ConnectionLifecycleHooks {
Expand Down
Loading
Loading