Skip to content

Commit 041ae8b

Browse files
feat(automations): add foundation — types, storage, scheduler, leader election (#323745)
* feat(automations): add foundation — types, storage service, scheduler, leader election Introduces the Automations subsystem core: - IAutomation types and IAutomationSchedule (interval-based: manual/hourly/daily/weekly) - DST-safe computeNextRunAt scheduling logic - IAutomationService with persistent JSON ledger (StorageScope.APPLICATION) - Optimistic concurrency via revision counter for cross-window safety - Leader election to ensure single scheduler instance across windows - AutomationScheduler: periodic tick loop checking due automations - Feature gate: chat.automations.enabled setting mirrored to context key - Unit tests for scheduling logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address Copilot Code Review feedback on PR 1 - Add _unsupportedSchema guard to recordRunStart and updateRun - Localize CRASH_RECOVERY_REASON string via nls.localize() - Move imports above registerSingleton call in contribution file - Fix test comment referencing wrong helper name (buildLocalDate -> localDate) * fix: address CCR round 2 — schema guards, telemetry cleanup, scope fix - Add _unsupportedSchema guard to markStaleRunsFailed and advanceNextRunAt - Replace boolean telemetry flags with actual enum values (permissionLevel, isolationMode) - Remove hasProviderId, hasSessionTypeId, hasModelId, hasMode, hasPermissionLevel flags - Add scope: ConfigurationScope.MACHINE to runTimeoutMinutes setting * fix: gate scheduler construction on chat.automations.enabled AutomationSchedulerCore (and its leader election heartbeat/storage writes) are now only constructed when the feature setting is enabled. Uses MutableDisposable for clean teardown on runtime toggle. Addresses CCR feedback: avoid 30s heartbeat timer + storage writes when the feature is disabled. * fix: isolate per-automation errors in dispatch loop Wrap advanceNextRunAt + runOneWithTimeout in try/catch so a failure in one automation does not abort dispatch of remaining due automations. * fix: register automations path in i18n resources Adds vs/sessions/contrib/automations to the translation resources so nls.localize calls pass the eslint translation-remind rule. * fix: validate enum fields at write boundary and harden persist() - Normalize permissionLevel/isolationMode in createAutomation and mergeAutomation: reject unknown values to keep persisted data and GDPR-classified telemetry low-cardinality. - Wrap persist() storage read and write in try/catch so storage failures degrade gracefully instead of breaking the scheduler. * fix: scheduler to Eventually phase, simplify persist(), document serial dispatch - Move scheduler registration from AfterRestored to Eventually — background concern that doesn't need to block workbench restore. - Remove re-read-before-write in persist() — refreshFromStorage via onDidChangeValue already handles cross-window sync. Eliminates redundant JSON.parse on every mutation and the misleading optimistic concurrency check that overwrote anyway. - Document that _pendingRuns serializes dispatch intentionally. * style: remove em dashes and obscure acronyms from comments Replace all em dashes with periods or semicolons. Remove TOCTOU acronym. Replace multiplication sign with plain x. * Signing commit --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e7bf9e8 commit 041ae8b

18 files changed

Lines changed: 2553 additions & 0 deletions

build/lib/i18n.resources.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,10 @@
644644
"name": "vs/sessions",
645645
"project": "vscode-sessions"
646646
},
647+
{
648+
"name": "vs/sessions/contrib/automations",
649+
"project": "vscode-sessions"
650+
},
647651
{
648652
"name": "vs/sessions/contrib/accountMenu",
649653
"project": "vscode-sessions"
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IntervalTimer } from '../../../../base/common/async.js';
7+
import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
8+
import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js';
9+
import { generateUuid } from '../../../../base/common/uuid.js';
10+
import { ILogService } from '../../../../platform/log/common/log.js';
11+
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
12+
13+
const LEADER_KEY = 'chat.automations.leader';
14+
15+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
16+
17+
// 3x heartbeat interval. Tolerates one missed write before failover.
18+
export const DEFAULT_STALE_AFTER_MS = 90_000;
19+
20+
interface ILeaderRecord {
21+
readonly instanceId: string;
22+
readonly heartbeatAt: number;
23+
// Per-write nonce to detect races during leader claims.
24+
readonly nonce: string;
25+
}
26+
27+
export interface IAutomationLeaderElectionOptions {
28+
readonly heartbeatIntervalMs?: number;
29+
readonly staleAfterMs?: number;
30+
readonly now?: () => number;
31+
readonly instanceId?: string;
32+
}
33+
34+
export class AutomationLeaderElection extends Disposable {
35+
36+
private readonly _isLeader: ISettableObservable<boolean>;
37+
readonly isLeader: IObservable<boolean>;
38+
39+
private readonly _instanceId: string;
40+
private readonly _heartbeatIntervalMs: number;
41+
private readonly _staleAfterMs: number;
42+
private readonly _now: () => number;
43+
44+
private readonly _timer = this._register(new IntervalTimer());
45+
46+
constructor(
47+
private readonly storageService: IStorageService,
48+
private readonly logService: ILogService,
49+
options: IAutomationLeaderElectionOptions = {},
50+
) {
51+
super();
52+
53+
this._instanceId = options.instanceId ?? generateUuid();
54+
this._heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
55+
this._staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
56+
this._now = options.now ?? Date.now;
57+
58+
this._isLeader = observableValue<boolean>(this, false);
59+
this.isLeader = this._isLeader;
60+
61+
this._register(toDisposable(() => this.releaseIfLeader()));
62+
63+
this.evaluate();
64+
65+
this._timer.cancelAndSet(() => this.evaluate(), this._heartbeatIntervalMs);
66+
}
67+
68+
get instanceId(): string {
69+
return this._instanceId;
70+
}
71+
72+
/** Test-only: drive the evaluation cycle synchronously. */
73+
evaluateForTesting(): void {
74+
this.evaluate();
75+
}
76+
77+
private evaluate(): void {
78+
const now = this._now();
79+
const current = this.readLeader();
80+
81+
const claimable =
82+
!current ||
83+
current.instanceId === this._instanceId ||
84+
current.instanceId === '' ||
85+
(now - current.heartbeatAt) > this._staleAfterMs;
86+
87+
if (!claimable) {
88+
if (this._isLeader.get()) {
89+
this.logService.info(`[AutomationLeaderElection] window ${this._instanceId} stood down for ${current!.instanceId}.`);
90+
}
91+
this._isLeader.set(false, undefined);
92+
return;
93+
}
94+
95+
// Write a nonce and verify we won by reading it back (narrows dual-leader window).
96+
const nonce = generateUuid();
97+
const writeOk = this.writeLeader({ instanceId: this._instanceId, heartbeatAt: now, nonce });
98+
if (!writeOk) {
99+
this._isLeader.set(false, undefined);
100+
return;
101+
}
102+
const verify = this.readLeader();
103+
if (verify?.instanceId === this._instanceId && verify.nonce === nonce) {
104+
if (!this._isLeader.get()) {
105+
this.logService.info(`[AutomationLeaderElection] window ${this._instanceId} claimed leader slot.`);
106+
}
107+
this._isLeader.set(true, undefined);
108+
} else {
109+
if (this._isLeader.get()) {
110+
this.logService.info(`[AutomationLeaderElection] window ${this._instanceId} lost leader race to ${verify?.instanceId ?? '<none>'}.`);
111+
}
112+
this._isLeader.set(false, undefined);
113+
}
114+
}
115+
116+
private readLeader(): ILeaderRecord | undefined {
117+
let raw: string | undefined;
118+
try {
119+
raw = this.storageService.get(LEADER_KEY, StorageScope.APPLICATION);
120+
} catch (err) {
121+
this.logService.warn('[AutomationLeaderElection] storage read failed', err);
122+
return undefined;
123+
}
124+
if (!raw) {
125+
return undefined;
126+
}
127+
try {
128+
const parsed = JSON.parse(raw) as ILeaderRecord;
129+
if (typeof parsed?.instanceId !== 'string' || typeof parsed?.heartbeatAt !== 'number') {
130+
return undefined;
131+
}
132+
// `nonce` is optional in older persisted records. Coerce to empty string.
133+
return { instanceId: parsed.instanceId, heartbeatAt: parsed.heartbeatAt, nonce: typeof parsed.nonce === 'string' ? parsed.nonce : '' };
134+
} catch {
135+
return undefined;
136+
}
137+
}
138+
139+
/** Returns true if the write succeeded, false if storage threw. */
140+
private writeLeader(record: ILeaderRecord): boolean {
141+
try {
142+
this.storageService.store(LEADER_KEY, JSON.stringify(record), StorageScope.APPLICATION, StorageTarget.MACHINE);
143+
return true;
144+
} catch (err) {
145+
this.logService.warn('[AutomationLeaderElection] storage write failed', err);
146+
return false;
147+
}
148+
}
149+
150+
// Write a tombstone on clean shutdown so the next window can claim immediately.
151+
private releaseIfLeader(): void {
152+
const current = this.readLeader();
153+
if (current?.instanceId !== this._instanceId) {
154+
return;
155+
}
156+
this.writeLeader({ instanceId: '', heartbeatAt: 0, nonce: '' });
157+
}
158+
}
159+
160+
export interface IAutomationLeaderElection extends IDisposable {
161+
readonly isLeader: IObservable<boolean>;
162+
readonly instanceId: string;
163+
evaluateForTesting(): void;
164+
}

0 commit comments

Comments
 (0)