agent-ecosystem/src/main/services/team/LaunchIoGovernor.ts

366 lines
11 KiB
TypeScript

import type {
GlobalTask,
TeamChangeEvent,
TeamProvisioningProgress,
TeamSummary,
} from '@shared/types';
export type LaunchIoGovernorOperationKey = 'teams:list' | 'teams:getAllTasks';
type GovernedPayload = TeamSummary[] | GlobalTask[];
type CloneFn<T> = (value: T) => T;
interface LaunchIoGovernorLogger {
debug?: (message: string) => void;
warn?: (message: string) => void;
}
export interface LaunchIoGovernorOptions {
quietWindowMs?: number;
maxStaleAgeMs?: number;
stuckLaunchPressureMs?: number;
warningCooldownMs?: number;
now?: () => number;
logger?: LaunchIoGovernorLogger;
}
interface ActiveLaunch {
teamName: string;
source: string;
startedAt: number;
updatedAt: number;
}
interface CachedValue<T> {
value: T;
cachedAt: number;
}
interface OperationState<T> {
key: LaunchIoGovernorOperationKey;
cache: CachedValue<T> | null;
dirty: boolean;
generation: number;
inFlight: Promise<T> | null;
loadFresh: (() => Promise<T>) | null;
clone: CloneFn<T> | null;
scheduledRefresh: ReturnType<typeof setTimeout> | null;
lastWarningAt: number;
}
export const DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS = 3_000;
export const DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS = 15_000;
export const DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS = 10 * 60_000;
const DEFAULT_WARNING_COOLDOWN_MS = 10_000;
const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'cancelled', 'disconnected']);
export function cloneLaunchIoGovernorPayload<T extends GovernedPayload>(value: T): T {
if (typeof structuredClone === 'function') {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
export class LaunchIoGovernor {
private readonly quietWindowMs: number;
private readonly maxStaleAgeMs: number;
private readonly stuckLaunchPressureMs: number;
private readonly warningCooldownMs: number;
private readonly now: () => number;
private readonly logger: LaunchIoGovernorLogger;
private readonly activeLaunches = new Map<string, ActiveLaunch>();
private readonly operations = new Map<
LaunchIoGovernorOperationKey,
OperationState<GovernedPayload>
>();
private quietUntil = 0;
constructor(options: LaunchIoGovernorOptions = {}) {
this.quietWindowMs = options.quietWindowMs ?? DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS;
this.maxStaleAgeMs = options.maxStaleAgeMs ?? DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS;
this.stuckLaunchPressureMs =
options.stuckLaunchPressureMs ?? DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS;
this.warningCooldownMs = options.warningCooldownMs ?? DEFAULT_WARNING_COOLDOWN_MS;
this.now = options.now ?? (() => Date.now());
this.logger = options.logger ?? {};
this.operations.set('teams:list', this.createOperationState('teams:list'));
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
}
noteLaunchIntent(teamName: string, source = 'unknown'): void {
const normalized = this.normalizeTeamName(teamName);
if (!normalized) {
return;
}
const now = this.now();
this.pruneStuckLaunches(now);
this.activeLaunches.set(normalized, {
teamName: normalized,
source,
startedAt: now,
updatedAt: now,
});
this.markDirty('teams:list');
this.scheduleDirtyRefreshes(false);
}
noteProvisioningProgress(progress: TeamProvisioningProgress): void {
const teamName = this.normalizeTeamName(progress.teamName);
if (!teamName) {
return;
}
const now = this.now();
this.pruneStuckLaunches(now);
this.markDirty('teams:list');
if (TERMINAL_PROVISIONING_STATES.has(String(progress.state))) {
this.activeLaunches.delete(teamName);
this.quietUntil = Math.max(this.quietUntil, now + this.quietWindowMs);
this.scheduleDirtyRefreshes(true);
return;
}
const existing = this.activeLaunches.get(teamName);
this.activeLaunches.set(teamName, {
teamName,
source: existing?.source ?? 'progress',
startedAt: existing?.startedAt ?? now,
updatedAt: now,
});
this.scheduleDirtyRefreshes(false);
}
noteTeamChange(event: TeamChangeEvent): void {
if (event.type === 'config') {
this.markDirty('teams:list');
this.markDirty('teams:getAllTasks');
} else if (event.type === 'task') {
this.markDirty('teams:getAllTasks');
}
if (this.hasLaunchPressure(this.now())) {
this.scheduleDirtyRefreshes(false);
}
}
async runSummaryOperation<T extends GovernedPayload>(
key: LaunchIoGovernorOperationKey,
loadFresh: () => Promise<T>,
options: { clone: CloneFn<T> }
): Promise<T> {
const state = this.getOperationState<T>(key);
state.loadFresh = loadFresh;
state.clone = options.clone;
if (this.canServeStale(state)) {
if (state.dirty) {
this.scheduleDeferredRefresh(key, state, false);
}
return options.clone(state.cache!.value);
}
return this.runFresh(key, state, false);
}
clearForTests(): void {
for (const state of this.operations.values()) {
if (state.scheduledRefresh) {
clearTimeout(state.scheduledRefresh);
}
}
this.activeLaunches.clear();
this.quietUntil = 0;
this.operations.clear();
this.operations.set('teams:list', this.createOperationState('teams:list'));
this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks'));
}
hasLaunchPressureForTests(): boolean {
return this.hasLaunchPressure(this.now());
}
private createOperationState<T extends GovernedPayload>(
key: LaunchIoGovernorOperationKey
): OperationState<T> {
return {
key,
cache: null,
dirty: false,
generation: 0,
inFlight: null,
loadFresh: null,
clone: null,
scheduledRefresh: null,
lastWarningAt: Number.NEGATIVE_INFINITY,
};
}
private getOperationState<T extends GovernedPayload>(
key: LaunchIoGovernorOperationKey
): OperationState<T> {
const state = this.operations.get(key);
if (!state) {
throw new Error(`Unknown launch IO governor operation: ${key}`);
}
return state as unknown as OperationState<T>;
}
private canServeStale<T extends GovernedPayload>(state: OperationState<T>): boolean {
const now = this.now();
if (!this.hasLaunchPressure(now) || !state.cache) {
return false;
}
return now - state.cache.cachedAt <= this.maxStaleAgeMs;
}
private async runFresh<T extends GovernedPayload>(
key: LaunchIoGovernorOperationKey,
state: OperationState<T>,
background: boolean
): Promise<T> {
if (!state.loadFresh || !state.clone) {
throw new Error(`Launch IO governor operation ${key} has no loader`);
}
if (state.inFlight) {
try {
const joined = await state.inFlight;
return state.clone(joined);
} catch (error) {
if (background) {
this.warnRefreshFailure(key, state, error);
}
throw error;
}
}
const generationAtStart = state.generation;
const loadFresh = state.loadFresh;
const clone = state.clone;
const promise = loadFresh();
state.inFlight = promise;
try {
const fresh = await promise;
if (state.generation === generationAtStart) {
state.cache = {
value: clone(fresh),
cachedAt: this.now(),
};
state.dirty = false;
}
return clone(fresh);
} catch (error) {
if (background) {
this.warnRefreshFailure(key, state, error);
}
throw error;
} finally {
if (state.inFlight === promise) {
state.inFlight = null;
}
}
}
private markDirty(key: LaunchIoGovernorOperationKey): void {
const state = this.getOperationState(key);
state.dirty = true;
state.generation += 1;
}
private scheduleDirtyRefreshes(force: boolean): void {
for (const [key, state] of this.operations) {
if (state.dirty) {
this.scheduleDeferredRefresh(key, state, force);
}
}
}
private scheduleDeferredRefresh<T extends GovernedPayload>(
key: LaunchIoGovernorOperationKey,
state: OperationState<T>,
force: boolean
): void {
if (!state.loadFresh || !state.clone) {
return;
}
if (state.scheduledRefresh) {
if (!force) {
return;
}
clearTimeout(state.scheduledRefresh);
state.scheduledRefresh = null;
}
const delayMs = this.getDelayUntilFreshAllowed(this.now());
state.scheduledRefresh = setTimeout(() => {
state.scheduledRefresh = null;
void this.flushOperation(key);
}, delayMs);
state.scheduledRefresh.unref?.();
}
private async flushOperation(key: LaunchIoGovernorOperationKey): Promise<void> {
const state = this.getOperationState(key);
const now = this.now();
if (this.hasLaunchPressure(now)) {
this.scheduleDeferredRefresh(key, state, true);
return;
}
if (!state.dirty || !state.loadFresh || !state.clone) {
return;
}
try {
await this.runFresh(key, state, true);
} catch {
// runFresh already emitted a bounded warning. Keep dirty=true so the next
// request or quiet-window timer can retry without losing the last-good cache.
}
}
private getDelayUntilFreshAllowed(now: number): number {
this.pruneStuckLaunches(now);
if (this.activeLaunches.size > 0) {
return this.quietWindowMs;
}
return Math.max(0, this.quietUntil - now);
}
private hasLaunchPressure(now: number): boolean {
this.pruneStuckLaunches(now);
return this.activeLaunches.size > 0 || now < this.quietUntil;
}
private pruneStuckLaunches(now: number): void {
for (const [teamName, launch] of this.activeLaunches) {
if (now - launch.updatedAt > this.stuckLaunchPressureMs) {
this.activeLaunches.delete(teamName);
this.logger.warn?.(
`[LaunchIoGovernor] launch pressure expired team=${teamName} source=${launch.source} ageMs=${now - launch.startedAt}`
);
}
}
}
private warnRefreshFailure<T extends GovernedPayload>(
key: LaunchIoGovernorOperationKey,
state: OperationState<T>,
error: unknown
): void {
const now = this.now();
if (now - state.lastWarningAt < this.warningCooldownMs) {
return;
}
state.lastWarningAt = now;
const ageMs = state.cache ? now - state.cache.cachedAt : null;
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.warn?.(
`[LaunchIoGovernor] deferred refresh failed op=${key} ageMs=${ageMs ?? 'none'} dirty=${state.dirty} activeLaunchCount=${this.activeLaunches.size} error=${errorMessage}`
);
}
private normalizeTeamName(teamName: string | undefined | null): string | null {
const normalized = teamName?.trim();
return normalized && normalized.length > 0 ? normalized : null;
}
}