Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/vs/sessions/AUTOMATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Every Automation store exposes whether its complete catalogue is `loading`, `rea

Provider stores map their connection and persistence lifecycle into this provider-neutral state. Agent Host stores become ready when an authoritative catalogue snapshot and every source still participating in the projection are readable, independently of migration authority. Known disconnect, disabled capability, and unsupported capability are unavailable rather than perpetually loading.

`ProviderAutomationService` keeps the initial aggregate loading until all AfterRestored workbench contributions have completed provider registration. A provider-less window then settles to its legacy-store state, so a legacy-only empty catalogue can be authoritative. After provider settlement, the aggregate reports `error` when any current store fails, otherwise `loading` while any store is loading, `unavailable` while any store is unavailable, and `ready` only when all current stores are ready.
`ProviderAutomationService` keeps the initial aggregate loading until all AfterRestored workbench contributions have completed provider registration. A provider-less window then settles to its legacy-store state, so a legacy-only empty catalogue can be authoritative. After provider settlement, the aggregate reports `error` when any current store fails, otherwise `loading` while any store is loading, and `unavailable` while an unavailable provider has evidence that it owns Automations. Provider-scoped legacy rows and the last authoritative provider catalogue supply that evidence. An unavailable provider with no ownership evidence does not make the aggregate catalogue incomplete.

## Multi-host routing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export class AutomationStore extends Disposable implements IAutomationStore {
readonly automations: IObservable<readonly IAutomationDescriptor[]>;
readonly runs: IObservable<readonly IAutomationRun[]>;
readonly catalogueState: IObservable<AutomationCatalogueState>;
readonly hasKnownAutomations: IObservable<boolean>;

constructor(
private readonly storageKey: string,
Expand All @@ -152,6 +153,7 @@ export class AutomationStore extends Disposable implements IAutomationStore {
this.automations = this._automations;
this.runs = this._runs;
this.catalogueState = this._catalogueState;
this.hasKnownAutomations = derived(this, reader => this._automations.read(reader).length > 0);

this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, this.storageKey, this._store)(() => {
this.refreshFromStorage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ export class ProviderAutomationService extends Disposable implements IAutomation
this.providersChanged = observableSignalFromEvent(this, sessionsProvidersService.onDidChangeProviders);
this.catalogueState = derived(this, reader => {
this.providersChanged.read(reader);
const states = this.getStores().map(entry => entry.store.catalogueState.read(reader));
const states = this.getStores().map(entry => {
const state = entry.store.catalogueState.read(reader);
return entry.providerId !== undefined && state === 'unavailable' && !entry.store.hasKnownAutomations.read(reader)
? 'ready'
: state;
});
if (!initialProvidersSettled.read(reader)) {
states.push('loading');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import assert from 'assert';
import { Emitter } from '../../../../../base/common/event.js';
import { autorun, type ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js';
import { autorun, derived, type ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { upcastPartial } from '../../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
Expand Down Expand Up @@ -33,11 +33,17 @@ class FailingStaleRunRecoveryAutomationStore extends AutomationStore {

class MutableCatalogueAutomationStore extends AutomationStore {
private readonly state = observableValue<AutomationCatalogueState>(this, 'ready');
private readonly knownAutomations = observableValue<boolean | undefined>(this, undefined);
override readonly catalogueState = this.state;
override readonly hasKnownAutomations = derived(this, reader => this.knownAutomations.read(reader) ?? this.automations.read(reader).length > 0);

setCatalogueState(state: AutomationCatalogueState, tx?: ITransaction): void {
this.state.set(state, tx);
}

setHasKnownAutomations(hasKnownAutomations: boolean, tx?: ITransaction): void {
this.knownAutomations.set(hasKnownAutomations, tx);
}
}

class MigrationDeferringAutomationStore extends AutomationStore {
Expand Down Expand Up @@ -250,6 +256,7 @@ suite('ProviderAutomationService', () => {
new NullLogService(),
automationStorage,
));
store.setHasKnownAutomations(true);
store.setCatalogueState('loading');
addProvider(upcastPartial<ISessionsProvider>({ id: 'stateful-provider', order: 1, automations: store }));
const loading = service.catalogueState.get();
Expand Down Expand Up @@ -327,6 +334,8 @@ suite('ProviderAutomationService', () => {
const { service, storage, automationStorage, addProvider } = createService();
const first = teardown.add(new MutableCatalogueAutomationStore('first', storage, new NullLogService(), automationStorage));
const second = teardown.add(new MutableCatalogueAutomationStore('second', storage, new NullLogService(), automationStorage));
first.setHasKnownAutomations(true);
second.setHasKnownAutomations(true);
addProvider(upcastPartial<ISessionsProvider>({ id: 'first', order: 1, automations: first }));
addProvider(upcastPartial<ISessionsProvider>({ id: 'second', order: 2, automations: second }));
let observedState: AutomationCatalogueState = 'ready';
Expand Down Expand Up @@ -393,7 +402,7 @@ suite('ProviderAutomationService', () => {
});
});

test('an unavailable remote catalogue does not block local automation operations', async () => {
test('ignores an unavailable provider without evidence of owned Automations', async () => {
const { service, providerStore, storage, automationStorage, addProvider } = createService();
const remote = teardown.add(new MutableCatalogueAutomationStore('remote', storage, new NullLogService(), automationStorage));
remote.setCatalogueState('unavailable');
Expand All @@ -417,7 +426,7 @@ suite('ProviderAutomationService', () => {
claimed: claim.claimed,
activeRunId: providerStore.getActiveRunFor(created.id)?.id,
}, {
catalogueState: 'unavailable',
catalogueState: 'ready',
localNames: ['Updated local review'],
remoteAutomations: [],
canRun: true,
Expand All @@ -427,6 +436,22 @@ suite('ProviderAutomationService', () => {
});
});

test('reports unavailable when an offline provider is known to own Automations', () => {
const { service, storage, automationStorage, addProvider } = createService();
const remote = teardown.add(new MutableCatalogueAutomationStore('remote', storage, new NullLogService(), automationStorage));
remote.setHasKnownAutomations(true);
remote.setCatalogueState('unavailable');
addProvider(upcastPartial<ISessionsProvider>({ id: 'remote', order: 1, automations: remote }));

assert.deepStrictEqual({
catalogueState: service.catalogueState.get(),
remoteAutomations: remote.automations.get(),
}, {
catalogueState: 'unavailable',
remoteAutomations: [],
});
});

test('transfers Automations and runs when updates change store ownership', async () => {
const { service, providerStore, storage } = createService();
const legacyTarget = { kind: 'workspace', folderUri: FOLDER, providerId: 'provider-without-storage', sessionTypeId: 'other', isolation: { kind: 'default' } } as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro
readonly automations: IObservable<readonly IAutomationDescriptor[]>;
readonly runs: IObservable<readonly IAutomationRun[]>;
readonly catalogueState: IObservable<AutomationCatalogueState>;
readonly hasKnownAutomations: IObservable<boolean>;

constructor(
private readonly _providerId: string,
Expand Down Expand Up @@ -163,6 +164,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro
return distinctById([...this._projectRuns(), ...this._archivedRuns.read(reader)])
.sort((first, second) => second.startedAt.localeCompare(first.startedAt));
});
this.hasKnownAutomations = derived(this, reader => this.automations.read(reader).length > 0);
}

getAutomation(id: string): IAutomationDescriptor | undefined {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements
private readonly _runsForCache = new Map<string, IObservable<readonly IAutomationRun[]>>();
private readonly _configurationChanged;
private readonly _authorityState = observableValue<AutomationAuthorityState>(this, { kind: 'disconnected' });
private readonly _lastKnownHostHasAutomations = observableValue(this, false);
private readonly _disposeCancellation = new CancellationTokenSource();

readonly automations = derived(this, reader => this._currentStore.read(reader)?.automations.read(reader) ?? this._legacySource?.automations.read(reader) ?? []);
readonly runs = derived(this, reader => this._currentStore.read(reader)?.runs.read(reader) ?? this._legacySource?.runs.read(reader) ?? []);
readonly hasKnownAutomations = derived(this, reader => (this._legacySource?.hasKnownAutomations.read(reader) ?? false) || this._lastKnownHostHasAutomations.read(reader));
readonly catalogueState: IObservable<AutomationCatalogueState> = derived(this, reader => {
const authorityState = this._authorityState.read(reader);
const legacyState = this._legacySource?.catalogueState.read(reader) ?? 'ready';
Expand All @@ -62,6 +64,12 @@ export class ReconnectableAgentHostAutomationStore extends Disposable implements
) {
super();
this._configurationChanged = observableSignalFromEvent(this, this._configurationService.onDidChangeConfiguration);
this._register(autorun(reader => {
const state = this._authorityState.read(reader);
if (state.kind === 'supported' && state.store.catalogueState.read(reader) === 'ready') {
this._lastKnownHostHasAutomations.set(state.store.hasKnownAutomations.read(reader), undefined);
}
}));
}

override dispose(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2220,26 +2220,30 @@ suite('AgentHostAutomationStore', () => {
catalogueState: store.catalogueState.read(reader),
});
}));
const initiallyDisconnected = store.catalogueState.get();
const initiallyDisconnected = { state: store.catalogueState.get(), hasKnownAutomations: store.hasKnownAutomations.get() };
const emissionsBeforeConnect = emissions.length;
store.setConnection(connection);
const connectEmissions = emissions.slice(emissionsBeforeConnect);
await store.createAutomation({
const created = await store.createAutomation({
name: 'Host automation',
prompt: 'Review changes.',
schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 },
target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' },
});
const connected = store.catalogueState.get();
const connected = { state: store.catalogueState.get(), hasKnownAutomations: store.hasKnownAutomations.get() };
const emissionsBeforeDisconnect = emissions.length;
store.clearConnection();
const disconnectEmissions = emissions.slice(emissionsBeforeDisconnect);
const afterDisconnect = store.catalogueState.get();
const afterDisconnect = { state: store.catalogueState.get(), hasKnownAutomations: store.hasKnownAutomations.get() };
connection.setCatalogAvailable(false);
store.setConnection(connection);
const duringReconnect = { state: store.catalogueState.get(), count: store.automations.get().length };
const duringReconnect = { state: store.catalogueState.get(), count: store.automations.get().length, hasKnownAutomations: store.hasKnownAutomations.get() };
connection.setCatalogAvailable();
await store.completeMigration();
const afterReconnect = { state: store.catalogueState.get(), count: store.automations.get().length, hasKnownAutomations: store.hasKnownAutomations.get() };
await store.deleteAutomation(created.id);
const afterDelete = { state: store.catalogueState.get(), count: store.automations.get().length, hasKnownAutomations: store.hasKnownAutomations.get() };
store.clearConnection();

assert.deepStrictEqual({
initiallyDisconnected,
Expand All @@ -2248,18 +2252,22 @@ suite('AgentHostAutomationStore', () => {
afterDisconnect,
disconnectEmissions,
duringReconnect,
afterReconnect: { state: store.catalogueState.get(), count: store.automations.get().length },
afterReconnect,
afterDelete,
afterEmptyDisconnect: { state: store.catalogueState.get(), count: store.automations.get().length, hasKnownAutomations: store.hasKnownAutomations.get() },
}, {
initiallyDisconnected: 'unavailable',
initiallyDisconnected: { state: 'unavailable', hasKnownAutomations: false },
connectEmissions: [
{ automationCount: 0, catalogueState: 'loading' },
{ automationCount: 0, catalogueState: 'ready' },
],
connected: 'ready',
afterDisconnect: 'unavailable',
connected: { state: 'ready', hasKnownAutomations: true },
afterDisconnect: { state: 'unavailable', hasKnownAutomations: true },
disconnectEmissions: [{ automationCount: 0, catalogueState: 'unavailable' }],
duringReconnect: { state: 'loading', count: 0 },
afterReconnect: { state: 'ready', count: 1 },
duringReconnect: { state: 'loading', count: 0, hasKnownAutomations: true },
afterReconnect: { state: 'ready', count: 1, hasKnownAutomations: true },
afterDelete: { state: 'ready', count: 0, hasKnownAutomations: false },
afterEmptyDisconnect: { state: 'unavailable', count: 0, hasKnownAutomations: false },
});
});

Expand All @@ -2276,15 +2284,23 @@ suite('AgentHostAutomationStore', () => {
});
const instantiationService = disposables.add(new TestInstantiationService());
const store = disposables.add(new ReconnectableAgentHostAutomationStore('remote-agent-host', legacy, undefined, instantiationService, new NullLogService(), new TestConfigurationService()));
const availableRows = { state: store.catalogueState.get(), names: store.automations.get().map(automation => automation.name) };
const availableRows = {
state: store.catalogueState.get(),
names: store.automations.get().map(automation => automation.name),
hasKnownAutomations: store.hasKnownAutomations.get(),
};
storage.store(storageKey, '{', StorageScope.APPLICATION, StorageTarget.MACHINE);

assert.deepStrictEqual({
availableRows,
afterError: { state: store.catalogueState.get(), names: store.automations.get().map(automation => automation.name) },
afterError: {
state: store.catalogueState.get(),
names: store.automations.get().map(automation => automation.name),
hasKnownAutomations: store.hasKnownAutomations.get(),
},
}, {
availableRows: { state: 'unavailable', names: ['Legacy automation'] },
afterError: { state: 'error', names: ['Legacy automation'] },
availableRows: { state: 'unavailable', names: ['Legacy automation'], hasKnownAutomations: true },
afterError: { state: 'error', names: ['Legacy automation'], hasKnownAutomations: true },
});
});

Expand Down
3 changes: 3 additions & 0 deletions src/vs/sessions/services/sessions/common/sessionsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Event } from '../../../../base/common/event.js';
import { IDisposable } from '../../../../base/common/lifecycle.js';
import type { IObservable } from '../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
Expand Down Expand Up @@ -130,6 +131,8 @@ export type IGuardedAutomationSnapshotRemovalResult =
| { readonly kind: 'missing' };

export interface ISessionsProviderAutomations extends IAutomationStore {
/** Whether there is evidence that this provider owns at least one Automation, including cached authoritative state while unavailable. */
readonly hasKnownAutomations: IObservable<boolean>;
canRunAutomation?(automationId: string): boolean;
canUpdateAutomation?(automationId: string): boolean;
canDeleteAutomation?(automationId: string): boolean;
Expand Down