From 3d2587b215ad5a6060d9ed441623433910d8153e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 27 May 2026 00:50:08 +0200 Subject: [PATCH 1/6] sessions: fix isolation picker and loading spinner regressions from Sessions Grid refactor (#318447) - IsNewChatSessionContext is now set to true while a pending new session exists (created but not yet sent). Previously the Sessions Grid refactor made _handleActiveSessionContextKeys set it to 'session === undefined', which was false for pending sessions, hiding the isolation/branch pickers on the new-session page. - Clear IsNewChatSessionContext to false in sendNewChatRequest when the first request is sent (matching the old behaviour where opening a session cleared the flag). - Sync the loading spinner's initial visibility right after its DOM element is created in render(). The autorun that toggles the spinner fires in the constructor before render() creates the DOM element, so the initial loading=true state was silently dropped on window reload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/contrib/chat/browser/newChatInput.ts | 1 + .../services/sessions/browser/sessionsManagementService.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index f1d811d913ebe..40f4aeb534f89 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -454,6 +454,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const loadingIcon = dom.append(this._loadingSpinner, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'))); loadingIcon.setAttribute('aria-hidden', 'true'); this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this._loadingSpinner, localize('loading', "Loading..."))); + this._loadingSpinner.classList.toggle('visible', this.options.loading.get()); const sendButtonContainer = dom.append(toolbar, dom.$('.sessions-chat-send-button')); const sendButton = this._sendButton = this._register(new Button(sendButtonContainer, { diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 6c97d65628932..6f5d8a74256f3 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -139,7 +139,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private _handleActiveSessionContextKeys(session: IActiveSession | undefined): void { // Update context keys from session data - this._isNewChatSessionContext.set(session === undefined); + // IsNewChatSessionContext is true when no active session exists, OR when the + // active session is still pending (created but not yet sent for the first time). + // Scoping to the active session avoids flipping into "new chat" mode while + // viewing a different established session. + this._isNewChatSessionContext.set(session === undefined || session.sessionId === this._pendingNewSession?.sessionId); this._activeSessionProviderId.set(session?.providerId ?? ''); this._activeSessionType.set(session?.sessionType ?? ''); this._activeSessionWorkspaceIsVirtual.set(session?.workspace.get()?.isVirtualWorkspace ?? true); @@ -464,6 +468,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise { this._pendingNewSession = undefined; + this._isNewChatSessionContext.set(false); // Kick off the workspace file-count fetch now so it has time to resolve // while the provider creates the chat and sends the request. The reporter From f935a5f658dba9720ac0504003445b9f1a53cc52 Mon Sep 17 00:00:00 2001 From: Premiermoney <286675216+Premiermoney@users.noreply.github.com> Date: Tue, 26 May 2026 18:51:10 -0400 Subject: [PATCH 2/6] Create octopusdeploy.yml Signed-off-by: Premiermoney <286675216+Premiermoney@users.noreply.github.com> --- .github/workflow_dispatch/octopusdeploy.yml | 112 ++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .github/workflow_dispatch/octopusdeploy.yml diff --git a/.github/workflow_dispatch/octopusdeploy.yml b/.github/workflow_dispatch/octopusdeploy.yml new file mode 100644 index 0000000000000..6a50d9c4ce8c6 --- /dev/null +++ b/.github/workflow_dispatch/octopusdeploy.yml @@ -0,0 +1,112 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by separate terms of service, +# privacy policy, and support documentation. +# +# This workflow will build and publish a Docker container which is then deployed through Octopus Deploy. +# +# The build job in this workflow currently assumes that there is a Dockerfile that generates the relevant application image. +# If required, this job can be modified to generate whatever alternative build artifact is required for your deployment. +# +# This workflow assumes you have already created a Project in Octopus Deploy. +# For instructions see https://octopus.com/docs/projects/setting-up-projects +# +# To configure this workflow: +# +# 1. Decide where you are going to host your image. +# This template uses the GitHub Registry for simplicity but if required you can update the relevant DOCKER_REGISTRY variables below. +# +# 2. Create and configure an OIDC credential for a service account in Octopus. +# This allows for passwordless authentication to your Octopus instance through a trust relationship configured between Octopus, GitHub and your GitHub Repository. +# https://octopus.com/docs/octopus-rest-api/openid-connect/github-actions +# +# 3. Configure your Octopus project details below: +# OCTOPUS_URL: update to your Octopus Instance Url +# OCTOPUS_SERVICE_ACCOUNT: update to your service account Id +# OCTOPUS_SPACE: update to the name of the space your project is configured in +# OCTOPUS_PROJECT: update to the name of your Octopus project +# OCTOPUS_ENVIRONMENT: update to the name of the environment to recieve the first deployment + + +name: 'Build and Deploy to Octopus Deploy' + +on: + push: + branches: + - '"main"' + +jobs: + build: + name: Build Linux + runs-on: ubuntu-latest + permissions: + packages: write + content: OneDrive + env: + DOCKER_REGISTRY: ghcr.io # TODO: Update to your docker registry uri + DOCKER_REGISTRY_USERNAME: ${{ github.actor }} # TODO: Update to your docker registry username + DOCKER_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} # TODO: Update to your docker registry password + outputs: + image_tag: ${{ steps.meta.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ env.DOCKER_REGISTRY_USERNAME }} + password: ${{ env.DOCKER_REGISTRY_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.DOCKER_REGISTRY }}/${{ github.repository }} + tags: type=semver,pattern={{version}},value=v1.0.0-{{sha}} + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + deploy: + name: Deploy + permissions: + id-token: write + runs-on: ubuntu-latest + needs: [ build ] + env: + OCTOPUS_URL: 'https://your-octopus-url' # TODO: update to your Octopus Instance url + OCTOPUS_SERVICE_ACCOUNT: 'your-service-account-id' # TODO: update to your service account Id + OCTOPUS_SPACE: 'your-space' # TODO: update to the name of the space your project is configured in + OCTOPUS_PROJECT: 'your-project' # TODO: update to the name of your Octopus project + OCTOPUS_ENVIRONMENT: 'your-environment' # TODO: update to the name of the environment to recieve the first deployment + + steps: + - name: Log in to Octopus Deploy + uses: OctopusDeploy/login@34b6dcc1e86fa373c14e6a28c5507d221e4de629 #v1.0.2 + with: + server: '${{ env.OCTOPUS_URL }}' + service_account_id: '${{ env.OCTOPUS_SERVICE_ACCOUNT }}' + + - name: Create Release + id: create_release + uses: OctopusDeploy/create-release-action@fea7e7b45c38c021b6bc5a14bd7eaa2ed5269214 #v3.2.2 + with: + project: '${{ env.OCTOPUS_PROJECT }}' + space: '${{ env.OCTOPUS_SPACE }}' + packages: '*:${{ needs.build.outputs.image_tag }}' + + - name: Deploy Release + uses: OctopusDeploy/deploy-release-action@b10a606c903b0a5bce24102af9d066638ab429ac #v3.2.1 + with: + project: '${{ env.OCTOPUS_PROJECT }}' + space: '${{ env.OCTOPUS_SPACE }}' + release_number: '${{ steps.create_release.outputs.release_number }}' + environments: ${{ env.OCTOPUS_ENVIRONMENT }} From f6907c39ccd5c8647c4544e722e4da5d072befde Mon Sep 17 00:00:00 2001 From: Premiermoney <286675216+Premiermoney@users.noreply.github.com> Date: Tue, 26 May 2026 18:53:02 -0400 Subject: [PATCH 3/6] Create CNAME --- CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 CNAME diff --git a/CNAME b/CNAME new file mode 100644 index 0000000000000..a13eb6e39257a --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +adventhealth.com \ No newline at end of file From ecb1d2628d8523ce68c06c51da49ae1d6c072d53 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Tue, 26 May 2026 15:53:49 -0700 Subject: [PATCH 4/6] Relax event flush cadence and filter delta events for session sync (#318444) * Relax event sync cadence and filter delta events * minor update * update retry timer * update ratelimit handling cadence * feedback update --- .../chronicle/common/eventTranslator.ts | 31 +++++ .../common/test/eventTranslator.spec.ts | 47 ++++++- .../vscode-node/remoteSessionExporter.ts | 128 +++++++++++++----- .../test/remoteSessionExporter.spec.ts | 90 ++++++++++++ 4 files changed, 264 insertions(+), 32 deletions(-) create mode 100644 extensions/copilot/src/extension/chronicle/vscode-node/test/remoteSessionExporter.spec.ts diff --git a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts index 663563a339f2a..d2fce64d64365 100644 --- a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts +++ b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts @@ -9,6 +9,37 @@ import type { ICompletedSpanData } from '../../../platform/otel/common/otelServi import type { IDebugLogEntry } from '../../../platform/chat/common/chatDebugFileLoggerService'; import type { SessionEvent, WorkingDirectoryContext } from './cloudSessionTypes'; +/** + * Per-token streaming events that must never be forwarded to the cloud. The + * current `translateSpan` does not emit any of these (assistant text is read + * from finalized `gen_ai.output.messages`), but the filter is kept defensively + * for parity with the CLI and for future translator changes. + */ +export const STREAMING_EVENT_TYPES: ReadonlySet = new Set([ + 'assistant.streaming_delta', + 'assistant.reasoning_delta', + 'assistant.message_delta', + 'tool.execution_partial_result', +]); + +/** + * Events that mark a natural "end of something" and should trigger a prompt + * flush. Other events are buffered until the 60s safety timer fires or the + * batch hits `MAX_EVENTS_PER_FLUSH`. + */ +export const TERMINAL_FLUSH_EVENT_TYPES: ReadonlySet = new Set([ + 'assistant.message', + 'tool.execution_complete', + 'session.idle', + 'session.shutdown', + 'session.error', +]); + +/** Returns true if the event marks a flush boundary for non-steerable sessions. */ +export function isTerminalFlushEvent(event: SessionEvent): boolean { + return TERMINAL_FLUSH_EVENT_TYPES.has(event.type); +} + // ── Event size limit (bytes) ──────────────────────────────────────────────────── // Whole events exceeding this size are dropped before buffering. diff --git a/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts index b34d1282d7433..c14787592f6d9 100644 --- a/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts +++ b/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from 'vitest'; import type { ICompletedSpanData } from '../../../../platform/otel/common/otelService'; import type { IDebugLogEntry } from '../../../../platform/chat/common/chatDebugFileLoggerService'; -import { createSessionTranslationState, deriveTitleFromUserMessage, makeIdleEvent, makeShutdownEvent, translateDebugLogEntry, translateSpan } from '../eventTranslator'; +import { createSessionTranslationState, deriveTitleFromUserMessage, isTerminalFlushEvent, makeIdleEvent, makeShutdownEvent, STREAMING_EVENT_TYPES, TERMINAL_FLUSH_EVENT_TYPES, translateDebugLogEntry, translateSpan } from '../eventTranslator'; +import type { SessionEvent } from '../cloudSessionTypes'; function makeSpan(overrides: Partial = {}): ICompletedSpanData { return { @@ -644,3 +645,47 @@ describe('deriveTitleFromUserMessage', () => { expect(deriveTitleFromUserMessage('')).toBeUndefined(); }); }); +describe('terminal / streaming event classification', () => { + function makeEvent(type: string): SessionEvent { + return { id: 'e', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, type, data: {} }; + } + + it('marks the documented terminal flush event types', () => { + expect(TERMINAL_FLUSH_EVENT_TYPES).toEqual(new Set([ + 'assistant.message', + 'tool.execution_complete', + 'session.idle', + 'session.shutdown', + 'session.error', + ])); + }); + + it('marks the documented streaming delta event types', () => { + expect(STREAMING_EVENT_TYPES).toEqual(new Set([ + 'assistant.streaming_delta', + 'assistant.reasoning_delta', + 'assistant.message_delta', + 'tool.execution_partial_result', + ])); + }); + + it('terminal and streaming sets are disjoint', () => { + for (const t of TERMINAL_FLUSH_EVENT_TYPES) { + expect(STREAMING_EVENT_TYPES.has(t)).toBe(false); + } + }); + + it('isTerminalFlushEvent recognizes terminal events', () => { + expect(isTerminalFlushEvent(makeEvent('assistant.message'))).toBe(true); + expect(isTerminalFlushEvent(makeEvent('tool.execution_complete'))).toBe(true); + expect(isTerminalFlushEvent(makeEvent('session.shutdown'))).toBe(true); + }); + + it('isTerminalFlushEvent returns false for non-terminal events', () => { + expect(isTerminalFlushEvent(makeEvent('session.start'))).toBe(false); + expect(isTerminalFlushEvent(makeEvent('user.message'))).toBe(false); + expect(isTerminalFlushEvent(makeEvent('assistant.usage'))).toBe(false); + expect(isTerminalFlushEvent(makeEvent('tool.execution_start'))).toBe(false); + expect(isTerminalFlushEvent(makeEvent('assistant.streaming_delta'))).toBe(false); + }); +}); diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index b528938a545fa..68896975752d3 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -23,6 +23,8 @@ import { CircuitBreaker } from '../common/circuitBreaker'; import { createSessionTranslationState, deriveTitleFromUserMessage, + isTerminalFlushEvent, + STREAMING_EVENT_TYPES, translateSpan, type SessionTranslationState, } from '../common/eventTranslator'; @@ -39,21 +41,25 @@ import { reindexSessions, reindexCloudSessions, type CloudReindexResult } from ' // ── Configuration ─────────────────────────────────────────────────────────────── -/** How often to flush buffered events to the cloud (ms). */ +/** + * Delay between a terminal event arriving and the resulting flush. Small enough + * to feel realtime, large enough to coalesce events from the same turn into one + * request. + */ const BATCH_INTERVAL_MS = 500; -/** Faster drain interval when buffer is above soft cap. */ -const FAST_BATCH_INTERVAL_MS = 200; +/** + * Safety-net interval for buffered events that did not trigger a terminal + * flush. + */ +export const SAFETY_INTERVAL_MS = 60_000; -/** Max events per flush request. */ +/** Max events per flush request — also acts as a buffer-size flush trigger. */ const MAX_EVENTS_PER_FLUSH = 500; /** Hard cap on buffered events (drop oldest beyond this). */ const MAX_BUFFER_SIZE = 1_000; -/** Soft cap — switch to faster drain. */ -const SOFT_BUFFER_CAP = 500; - /** Max CHAT spans buffered per session while awaiting INVOKE_AGENT. */ const MAX_PENDING_CHAT_SPANS_PER_SESSION = 32; @@ -64,7 +70,11 @@ const POLICY_BLOCKED_TTL_MS = 60 * 60 * 1000; * Exports VS Code chat session events to the cloud in real-time. * * - Listens to OTel spans, translates to cloud SessionEvent format - * - Buffers events and flushes in batches every 500ms + * - Buffers events; flushes within ~500ms when a terminal event arrives + * (assistant.message, tool.execution_complete) or when the buffer reaches + * {@link MAX_EVENTS_PER_FLUSH}, otherwise waits up to + * {@link SAFETY_INTERVAL_MS} as a safety net + * - Streaming delta events are dropped before buffering * - Circuit breaker prevents cascading failures when the cloud is unavailable * - Lazy initialization: no work until the first real chat interaction * @@ -113,7 +123,8 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr private readonly _cloudClient: CloudSessionApiClient; private readonly _circuitBreaker: CircuitBreaker; - private _flushTimer: ReturnType | undefined; + private _flushTimer: ReturnType | undefined; + private _flushTimerKind: 'fast' | 'safety' | undefined; private _isFlushing = false; private _firstCloudWriteLogged = false; @@ -321,10 +332,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } override dispose(): void { - if (this._flushTimer !== undefined) { - clearInterval(this._flushTimer); - this._flushTimer = undefined; - } + this._stopFlushTimer(); // Best-effort final flush with timeout const pending = this._eventBuffer.length; @@ -781,7 +789,6 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr if (events.length > 0) { this._bufferEvents(sessionId, events); - this._ensureFlushTimer(); } // Replay any CHAT spans that arrived before session initialization @@ -796,6 +803,11 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } } } + // Parent INVOKE_AGENT completion marks a turn boundary. Force a fast + // flush even when the turn produced no terminal event (e.g. cancelled + // before any tokens streamed) so buffered user.message/session.start + // don't sit until the 60s safety timer. + this._scheduleFlush(BATCH_INTERVAL_MS); } } catch { // Non-fatal — individual span processing failure @@ -1124,9 +1136,28 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // ── Buffering ──────────────────────────────────────────────────────────────── + /** + * Buffer events, drop streaming deltas, and schedule a flush. + * + * Scheduling cadence: + * - terminal event present in the batch → fast flush ({@link BATCH_INTERVAL_MS}) + * - buffer at/over {@link MAX_EVENTS_PER_FLUSH} → fast flush + * - otherwise → safety flush ({@link SAFETY_INTERVAL_MS}) + */ private _bufferEvents(chatSessionId: string, events: SessionEvent[]): void { + let hasTerminal = false; for (const event of events) { + if (STREAMING_EVENT_TYPES.has(event.type)) { + continue; + } this._eventBuffer.push({ chatSessionId, event }); + if (!hasTerminal && isTerminalFlushEvent(event)) { + hasTerminal = true; + } + } + + if (this._eventBuffer.length === 0) { + return; } // Hard cap — drop oldest events @@ -1140,20 +1171,34 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr bufferSize: MAX_BUFFER_SIZE, }); } + + if (hasTerminal || this._eventBuffer.length >= MAX_EVENTS_PER_FLUSH) { + this._scheduleFlush(BATCH_INTERVAL_MS); + } else { + this._scheduleFlush(SAFETY_INTERVAL_MS); + } } - // ── Flush timer ────────────────────────────────────────────────────────────── + // ── Flush scheduling ───────────────────────────────────────────────────────── + + /** + * Schedule a one-shot flush. Upgrade-only: a pending fast flush is never + * downgraded by a later safety request. + */ + private _scheduleFlush(intervalMs: number): void { + const kind: 'fast' | 'safety' = intervalMs === SAFETY_INTERVAL_MS ? 'safety' : 'fast'; - private _ensureFlushTimer(): void { if (this._flushTimer !== undefined) { - return; + if (kind === 'safety' || this._flushTimerKind === 'fast') { + return; + } + clearTimeout(this._flushTimer); } - const interval = this._eventBuffer.length > SOFT_BUFFER_CAP - ? FAST_BATCH_INTERVAL_MS - : BATCH_INTERVAL_MS; - - this._flushTimer = setInterval(() => { + this._flushTimerKind = kind; + this._flushTimer = setTimeout(() => { + this._flushTimer = undefined; + this._flushTimerKind = undefined; this._flushBatch().catch(err => { this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle.cloudSync', { @@ -1162,13 +1207,14 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr error: err instanceof Error ? err.message.substring(0, 100) : 'unknown', }, {}); }); - }, interval); + }, intervalMs); } private _stopFlushTimer(): void { if (this._flushTimer !== undefined) { - clearInterval(this._flushTimer); + clearTimeout(this._flushTimer); this._flushTimer = undefined; + this._flushTimerKind = undefined; } } @@ -1180,9 +1226,6 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } if (this._eventBuffer.length === 0) { - if (this._cloudSessions.size === 0) { - this._stopFlushTimer(); - } return; } @@ -1191,6 +1234,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr const dropped = this._eventBuffer.length - MAX_BUFFER_SIZE; this._eventBuffer.splice(0, dropped); } + // Re-arm at the safety cadence so buffered events are retried once the + // breaker transitions to HALF_OPEN, even if no new spans arrive. + this._scheduleFlush(SAFETY_INTERVAL_MS); return; } @@ -1202,6 +1248,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // Release the probe slot consumed by canRequest() above so we don't // burn it on a flush we never actually attempted. this._circuitBreaker.cancelProbe(); + // Re-arm at the safety cadence so buffered events are retried once the + // client's Retry-After window elapses, even if no new spans arrive. + this._scheduleFlush(SAFETY_INTERVAL_MS); return; } @@ -1211,6 +1260,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr const uniqueSessionsInBatch = new Set(batch.map(e => e.chatSessionId)).size; this._setSyncState({ kind: 'syncing', sessionCount: uniqueSessionsInBatch }); + let flushFailed = false; try { // Group events by chat session ID for correct cloud session routing const eventsBySession = new Map; entries: typeof batch }>(); @@ -1289,9 +1339,12 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } } else if (result.reason === 'rate_limited') { // Client is already self-backing-off; don't trip the circuit breaker. - // Requeue the unsent events so they're retried after the backoff. + // Requeue the unsent events so they're retried after the backoff, and + // fall into the safety cadence (60s) so we don't busy-poll the + // isRateLimited() flag at the fast batch interval until it lifts. rateLimitedSessions++; requeueOnRateLimit.push(...slot.entries); + flushFailed = true; } else { hadTransientError = true; } @@ -1324,6 +1377,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } else if (hadTransientError) { this._circuitBreaker.recordFailure(); this._setSyncState({ kind: 'error' }); + flushFailed = true; this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { operation: 'flushFailure', @@ -1366,6 +1420,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // Re-queue on unexpected error this._eventBuffer.unshift(...batch); this._circuitBreaker.recordFailure(); + flushFailed = true; this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle.cloudSync', { operation: 'flushBatch', @@ -1377,9 +1432,20 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._isFlushing = false; } - if (this._eventBuffer.length > SOFT_BUFFER_CAP && this._flushTimer !== undefined) { - this._stopFlushTimer(); - this._ensureFlushTimer(); + // Re-arm after a flush: + // - transient failure or rate limit → back off at safety cadence (60s) + // so we don't busy-loop against a failing or throttled endpoint; + // the circuit breaker (for failures) and the client's own backoff + // (for rate limits) still gate the actual request independently + // - buffer still has work (re-queued orphans) → fast flush + // - otherwise, keep a safety timer running while any cloud session is active + // so late spans are caught even without a terminal event + if (flushFailed && this._eventBuffer.length > 0) { + this._scheduleFlush(SAFETY_INTERVAL_MS); + } else if (this._eventBuffer.length > 0) { + this._scheduleFlush(BATCH_INTERVAL_MS); + } else if (this._cloudSessions.size > 0) { + this._scheduleFlush(SAFETY_INTERVAL_MS); } } diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/test/remoteSessionExporter.spec.ts b/extensions/copilot/src/extension/chronicle/vscode-node/test/remoteSessionExporter.spec.ts new file mode 100644 index 0000000000000..2ed7190a35c48 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/vscode-node/test/remoteSessionExporter.spec.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, vi } from 'vitest'; +import { RemoteSessionExporter, SAFETY_INTERVAL_MS } from '../remoteSessionExporter'; + +/** + * Focused tests for the early-return safety re-arm in `_flushBatch`. + * + * The full RemoteSessionExporter has ~13 service dependencies, so these tests + * bypass the constructor with `Object.create` and only seed the private fields + * that the early-return paths touch. We assert that `_scheduleFlush` is invoked + * with `SAFETY_INTERVAL_MS` so buffered work is guaranteed to be retried once + * the breaker / rate-limit window elapses, even if no further spans arrive. + */ + +interface ExporterStubs { + scheduleFlush: ReturnType; + cancelProbe: ReturnType; +} + +function makeStubExporter(opts: { + breakerOpen?: boolean; + rateLimited?: boolean; + bufferLength?: number; +}): { exporter: RemoteSessionExporter; stubs: ExporterStubs } { + const scheduleFlush = vi.fn(); + const cancelProbe = vi.fn(); + + const exporter = Object.create(RemoteSessionExporter.prototype) as RemoteSessionExporter; + const fields = exporter as unknown as { + _isFlushing: boolean; + _eventBuffer: { chatSessionId: string; event: { type: string } }[]; + _circuitBreaker: { canRequest: () => boolean; cancelProbe: () => void }; + _cloudClient: { isRateLimited: () => boolean }; + _scheduleFlush: (intervalMs: number) => void; + }; + + fields._isFlushing = false; + fields._eventBuffer = Array.from({ length: opts.bufferLength ?? 1 }, (_, i) => ({ + chatSessionId: `s${i}`, + event: { type: 'assistant.message' }, + })); + fields._circuitBreaker = { + canRequest: () => !opts.breakerOpen, + cancelProbe, + }; + fields._cloudClient = { + isRateLimited: () => !!opts.rateLimited, + }; + fields._scheduleFlush = scheduleFlush; + + return { exporter, stubs: { scheduleFlush, cancelProbe } }; +} + +async function invokeFlushBatch(exporter: RemoteSessionExporter): Promise { + await (exporter as unknown as { _flushBatch: () => Promise })._flushBatch(); +} + +describe('RemoteSessionExporter._flushBatch early-return safety re-arm', () => { + it('arms a safety-cadence flush when the circuit breaker is open', async () => { + const { exporter, stubs } = makeStubExporter({ breakerOpen: true }); + + await invokeFlushBatch(exporter); + + expect(stubs.scheduleFlush).toHaveBeenCalledTimes(1); + expect(stubs.scheduleFlush).toHaveBeenCalledWith(SAFETY_INTERVAL_MS); + }); + + it('arms a safety-cadence flush when the client is rate-limited', async () => { + const { exporter, stubs } = makeStubExporter({ rateLimited: true }); + + await invokeFlushBatch(exporter); + + expect(stubs.scheduleFlush).toHaveBeenCalledTimes(1); + expect(stubs.scheduleFlush).toHaveBeenCalledWith(SAFETY_INTERVAL_MS); + // Probe slot consumed by canRequest() must be released. + expect(stubs.cancelProbe).toHaveBeenCalledTimes(1); + }); + + it('does not arm a flush when the buffer is empty (benign no-op)', async () => { + const { exporter, stubs } = makeStubExporter({ bufferLength: 0 }); + + await invokeFlushBatch(exporter); + + expect(stubs.scheduleFlush).not.toHaveBeenCalled(); + }); +}); From 438692e07b4bd6752d5b286d270685b9436e60f3 Mon Sep 17 00:00:00 2001 From: Premiermoney <286675216+Premiermoney@users.noreply.github.com> Date: Tue, 26 May 2026 19:05:46 -0400 Subject: [PATCH 5/6] Create code-coverage.yml Signed-off-by: Premiermoney <286675216+Premiermoney@users.noreply.github.com> --- .github/workflows/code-coverage.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/code-coverage.yml diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 0000000000000..058b48548b973 --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,19 @@ + + + +#UnknownAsNull + + + + + + + + + + + + #UnknownAsNull +#UnknownAsNull +#UnknownAsNull +#UnknownAsNull From b797ac152c1e7882e1ae4081c4400939ec8d3e64 Mon Sep 17 00:00:00 2001 From: Premiermoney <286675216+Premiermoney@users.noreply.github.com> Date: Tue, 26 May 2026 19:51:29 -0400 Subject: [PATCH 6/6] Create codespaces Signed-off-by: Premiermoney <286675216+Premiermoney@users.noreply.github.com> --- .github/workflows/codespaces | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/workflows/codespaces diff --git a/.github/workflows/codespaces b/.github/workflows/codespaces new file mode 100644 index 0000000000000..4a2ddcaf6f5fa --- /dev/null +++ b/.github/workflows/codespaces @@ -0,0 +1 @@ +DIFFERENT