perf: scope team file watching to active and engaged teams
The main process watched every team directory under ~/.claude/teams (one shallow chokidar target per team root, per team inboxes, and per task dir). On macOS this falls back to kqueue, which needs one fd per watched file, so a workspace with many teams kept ~1600 descriptors open and made startup and reconcile work scale with the number of teams on disk. Scope the team-root and task watching to teams that are running or currently engaged in the UI. The teams root and every team's inboxes are still watched for all teams, so cross-team message delivery, the lead inbox->stdin relay, and notifications are unchanged. Idle teams are static, so dropping their team-root/ task watches is safe; opening a team (getData) or launching it re-adds it via an immediate watch-scope refresh. The provider falls back to watching every team when unset, and the EMFILE polling fallback is intentionally left unscoped so a scope change can never look like a deletion. Measured on a 162-team workspace: open team fds 1600 -> 730, with team-root watching restored the moment a team is opened or goes live.
This commit is contained in:
parent
d06ea7f265
commit
5d63ecfe32
8 changed files with 416 additions and 1 deletions
|
|
@ -62,6 +62,11 @@ import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '@main/services/runtime/ope
|
|||
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
||||
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||
import {
|
||||
computeTeamWatchScope,
|
||||
setAliveTeamsProvider,
|
||||
setTeamWatchScopeChangeListener,
|
||||
} from '@main/services/infrastructure/teamWatchScope';
|
||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||
|
|
@ -1412,8 +1417,22 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
}
|
||||
};
|
||||
context.fileWatcher.on('team-change', teamChangeHandler);
|
||||
|
||||
// Scope team-root/task file watching to alive + UI-engaged teams so it no longer
|
||||
// scales with the number of teams on disk. Inboxes and the teams root stay fully
|
||||
// watched, so cross-team delivery, the lead inbox relay, and notifications are
|
||||
// unaffected. Unsetting the provider on cleanup reverts to watching every team.
|
||||
setAliveTeamsProvider(() => teamProvisioningService.getAliveTeamNames());
|
||||
setTeamWatchScopeChangeListener(() => {
|
||||
void context.fileWatcher.refreshTeamWatchScope();
|
||||
});
|
||||
context.fileWatcher.setTeamWatchScopeProvider(() => computeTeamWatchScope());
|
||||
void context.fileWatcher.refreshTeamWatchScope();
|
||||
|
||||
teamChangeCleanup = () => {
|
||||
context.fileWatcher.off('team-change', teamChangeHandler);
|
||||
setTeamWatchScopeChangeListener(null);
|
||||
context.fileWatcher.setTeamWatchScopeProvider(null);
|
||||
reconcileScheduler?.dispose();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
} from '@features/agent-attachments/contracts';
|
||||
import { addMainBreadcrumb } from '@main/sentry';
|
||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||
import { markTeamEngaged } from '@main/services/infrastructure/teamWatchScope';
|
||||
import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient';
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
|
|
@ -869,6 +870,9 @@ async function handleGetData(
|
|||
return { success: false, error: optionsResult.error };
|
||||
}
|
||||
const tn = validated.value!;
|
||||
// The UI is fetching this team, so keep its team-root/task artifacts watched
|
||||
// (idle teams the UI never opens are not watched, to scale with team count).
|
||||
markTeamEngaged(tn);
|
||||
const getDataOptions = optionsResult.value;
|
||||
const startedAt = Date.now();
|
||||
let data: TeamViewSnapshot;
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ export class FileWatcher extends EventEmitter {
|
|||
private todosPath: string;
|
||||
private teamsPath: string;
|
||||
private tasksPath: string;
|
||||
// Optional scope for team-root/task watching (alive ∪ engaged teams). Inboxes
|
||||
// and the teams root are always watched. Null => watch every team (fallback).
|
||||
private teamWatchScopeProvider: (() => ReadonlySet<string> | null) | null = null;
|
||||
private teamsRegistry: TeamTaskWatchRegistry | null = null;
|
||||
private tasksRegistry: TeamTaskWatchRegistry | null = null;
|
||||
private dataCache: DataCache;
|
||||
private fsProvider: FileSystemProvider;
|
||||
private notificationManager: NotificationManager | null = null;
|
||||
|
|
@ -246,6 +251,31 @@ export class FileWatcher extends EventEmitter {
|
|||
/**
|
||||
* Sets the filesystem provider. Used when switching between local and SSH modes.
|
||||
*/
|
||||
/**
|
||||
* Inject the provider that decides which teams' team-root and task artifacts
|
||||
* are watched (typically alive ∪ engaged teams). The teams root and every
|
||||
* team's inboxes are always watched. Returning null (or leaving the provider
|
||||
* unset) watches every team — the safe fallback / original behavior.
|
||||
*
|
||||
* Only the chokidar registry path is scoped; the EMFILE polling fallback still
|
||||
* watches every team so a scope change can never be mistaken for a deletion.
|
||||
*/
|
||||
setTeamWatchScopeProvider(provider: (() => ReadonlySet<string> | null) | null): void {
|
||||
this.teamWatchScopeProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the watched team set immediately, e.g. right after a team launches,
|
||||
* stops, or becomes engaged in the UI. Safe to call frequently: it no-ops when
|
||||
* the resolved target set is unchanged and coalesces with in-flight reconciles.
|
||||
*/
|
||||
async refreshTeamWatchScope(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.teamsRegistry?.requestReconcile(),
|
||||
this.tasksRegistry?.requestReconcile(),
|
||||
]);
|
||||
}
|
||||
|
||||
setFileSystemProvider(provider: FileSystemProvider): void {
|
||||
this.fsProvider = provider;
|
||||
}
|
||||
|
|
@ -545,8 +575,15 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
},
|
||||
onError,
|
||||
getScopedTeamNames: () => this.teamWatchScopeProvider?.() ?? null,
|
||||
});
|
||||
|
||||
if (watcherType === 'teams') {
|
||||
this.teamsRegistry = registry;
|
||||
} else {
|
||||
this.tasksRegistry = registry;
|
||||
}
|
||||
|
||||
try {
|
||||
await registry.start();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,20 @@ export interface TeamTaskWatchRegistryOptions {
|
|||
rootPath: string;
|
||||
onChange: (eventType: TeamTaskWatchEventType, relativePath: string) => void;
|
||||
onError: (error: unknown) => void;
|
||||
/**
|
||||
* Optional provider for the set of team names whose team-root and task
|
||||
* artifacts should be watched. The root directory is always watched (to detect
|
||||
* new/removed teams), and for the 'teams' kind every team's `inboxes/` is
|
||||
* always watched (cross-team message delivery and notifications must stay
|
||||
* immediate). Return `null` (or omit the provider) to watch every team — the
|
||||
* original behavior and the safe fallback.
|
||||
*
|
||||
* Scoping exists because team-root (config/kanban/processes/meta) and task
|
||||
* artifacts only change for teams that are running or currently engaged in the
|
||||
* UI; idle teams are static, so watching all of them is pure overhead that
|
||||
* scales with the number of teams on disk.
|
||||
*/
|
||||
getScopedTeamNames?: () => ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
const RECONCILE_INTERVAL_MS = 30_000;
|
||||
|
|
@ -76,6 +90,17 @@ export class TeamTaskWatchRegistry {
|
|||
this.reconcileTimer.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an immediate target reconciliation. Call this when the scoped team set
|
||||
* changes (a team launches, stops, or becomes engaged in the UI) so the watch
|
||||
* set updates without waiting for the periodic reconcile. Safe to call often:
|
||||
* it no-ops when the resulting target set is unchanged and coalesces with any
|
||||
* in-flight reconcile.
|
||||
*/
|
||||
async requestReconcile(): Promise<void> {
|
||||
await this.reconcileTargets();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.generation += 1;
|
||||
|
|
@ -237,6 +262,8 @@ export class TeamTaskWatchRegistry {
|
|||
// emitting user-visible events for those artifacts.
|
||||
const targets = new Set<string>([path.normalize(this.options.rootPath)]);
|
||||
const rootEntries = await this.readDirectory(this.options.rootPath);
|
||||
// null => no scoping: watch every team (original behavior / safe fallback).
|
||||
const scopedTeams = this.options.getScopedTeamNames?.() ?? null;
|
||||
|
||||
for (const entry of rootEntries) {
|
||||
if (!entry.isDirectory()) {
|
||||
|
|
@ -244,7 +271,14 @@ export class TeamTaskWatchRegistry {
|
|||
}
|
||||
|
||||
const teamPath = path.join(this.options.rootPath, entry.name);
|
||||
targets.add(path.normalize(teamPath));
|
||||
const inScope = scopedTeams === null || scopedTeams.has(entry.name);
|
||||
|
||||
// Team-root and task artifacts only change for running/engaged teams, so
|
||||
// scope those. Inboxes are always watched so cross-team delivery and
|
||||
// notifications to non-visible teams stay immediate.
|
||||
if (inScope) {
|
||||
targets.add(path.normalize(teamPath));
|
||||
}
|
||||
|
||||
if (this.options.kind === 'teams') {
|
||||
const inboxPath = path.join(teamPath, 'inboxes');
|
||||
|
|
|
|||
82
src/main/services/infrastructure/teamWatchScope.ts
Normal file
82
src/main/services/infrastructure/teamWatchScope.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Decides which teams' team-root and task artifacts should be file-watched.
|
||||
*
|
||||
* The scope is (teams with a live runtime run) ∪ (teams recently engaged in the
|
||||
* UI). FileWatcher always watches the teams root and every team's `inboxes/`
|
||||
* regardless of this scope, so cross-team message delivery, the lead inbox→stdin
|
||||
* relay, and notifications are unaffected. This module only narrows the heavier
|
||||
* per-team team-root (config/kanban/processes/meta) and task watching, which
|
||||
* otherwise scales with the number of teams on disk and dominates startup cost.
|
||||
*
|
||||
* Module-level state mirrors the existing IPC/registry singletons in this layer.
|
||||
*/
|
||||
|
||||
const ENGAGED_TTL_MS = 5 * 60_000;
|
||||
|
||||
const engagedAtByTeam = new Map<string, number>();
|
||||
let aliveTeamsProvider: (() => Iterable<string>) | null = null;
|
||||
let scopeChangeListener: (() => void) | null = null;
|
||||
|
||||
export function setAliveTeamsProvider(provider: (() => Iterable<string>) | null): void {
|
||||
aliveTeamsProvider = provider;
|
||||
}
|
||||
|
||||
export function setTeamWatchScopeChangeListener(listener: (() => void) | null): void {
|
||||
scopeChangeListener = listener;
|
||||
}
|
||||
|
||||
function collectAliveTeams(scope: Set<string>): void {
|
||||
if (!aliveTeamsProvider) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (const teamName of aliveTeamsProvider()) {
|
||||
if (teamName) {
|
||||
scope.add(teamName);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A provider failure must never break watching. The watcher treats a thrown
|
||||
// or empty scope conservatively (inboxes + root stay watched either way).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current set of teams whose team-root/task artifacts should be watched. Prunes
|
||||
* engaged entries past their TTL as a side effect of being called.
|
||||
*/
|
||||
export function computeTeamWatchScope(nowMs: number = Date.now()): ReadonlySet<string> {
|
||||
const scope = new Set<string>();
|
||||
collectAliveTeams(scope);
|
||||
for (const [teamName, engagedAt] of engagedAtByTeam) {
|
||||
if (nowMs - engagedAt <= ENGAGED_TTL_MS) {
|
||||
scope.add(teamName);
|
||||
} else {
|
||||
engagedAtByTeam.delete(teamName);
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a team as engaged in the UI (opened or refreshed). Notifies the scope
|
||||
* change listener only when this newly brings the team into scope, so repeated
|
||||
* calls for an already-watched team stay cheap and do not churn the watcher.
|
||||
*/
|
||||
export function markTeamEngaged(teamName: string, nowMs: number = Date.now()): void {
|
||||
if (!teamName) {
|
||||
return;
|
||||
}
|
||||
const wasInScope = computeTeamWatchScope(nowMs).has(teamName);
|
||||
engagedAtByTeam.set(teamName, nowMs);
|
||||
if (!wasInScope) {
|
||||
scopeChangeListener?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper: clear engaged state and wiring. */
|
||||
export function resetTeamWatchScopeForTests(): void {
|
||||
engagedAtByTeam.clear();
|
||||
aliveTeamsProvider = null;
|
||||
scopeChangeListener = null;
|
||||
}
|
||||
|
|
@ -4881,6 +4881,15 @@ export class TeamProvisioningService {
|
|||
return this.aliveRunByTeam.get(teamName) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of teams that currently have a live runtime run. Used to keep the
|
||||
* file-watch scope covering running teams (read-only; the map is maintained as
|
||||
* runs start and stop).
|
||||
*/
|
||||
getAliveTeamNames(): string[] {
|
||||
return [...this.aliveRunByTeam.keys()];
|
||||
}
|
||||
|
||||
private getTrackedRunId(teamName: string): string | null {
|
||||
return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName);
|
||||
}
|
||||
|
|
|
|||
159
test/main/services/infrastructure/TeamTaskWatchRegistry.test.ts
Normal file
159
test/main/services/infrastructure/TeamTaskWatchRegistry.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type MockChokidarWatcher = {
|
||||
targets: string[];
|
||||
options: unknown;
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => MockChokidarWatcher;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const chokidarMock = vi.hoisted(() => {
|
||||
const instances: MockChokidarWatcher[] = [];
|
||||
const make = () => (targets: string | string[], options: unknown) => {
|
||||
const watcher = {
|
||||
targets: (Array.isArray(targets) ? targets : [targets]).map((t) => String(t)),
|
||||
options,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
} as MockChokidarWatcher;
|
||||
watcher.on = vi.fn(() => watcher);
|
||||
instances.push(watcher);
|
||||
return watcher;
|
||||
};
|
||||
const watch = vi.fn(make());
|
||||
return {
|
||||
instances,
|
||||
watch,
|
||||
reset() {
|
||||
instances.length = 0;
|
||||
watch.mockReset();
|
||||
watch.mockImplementation(make());
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('chokidar', () => ({ watch: chokidarMock.watch }));
|
||||
|
||||
import { TeamTaskWatchRegistry } from '../../../../src/main/services/infrastructure/TeamTaskWatchRegistry';
|
||||
|
||||
function latestTargets(): string[] {
|
||||
const last = chokidarMock.instances.at(-1);
|
||||
return (last?.targets ?? []).map((t) => path.normalize(t));
|
||||
}
|
||||
|
||||
describe('TeamTaskWatchRegistry scoping', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
chokidarMock.reset();
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'ttwr-scope-'));
|
||||
for (const team of ['alpha', 'beta', 'gamma']) {
|
||||
fs.mkdirSync(path.join(root, team, 'inboxes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, team, 'config.json'), '{}');
|
||||
fs.writeFileSync(path.join(root, team, 'inboxes', 'team-lead.json'), '[]');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('watches only scoped team dirs but every team inbox (teams kind)', async () => {
|
||||
const registry = new TeamTaskWatchRegistry({
|
||||
kind: 'teams',
|
||||
rootPath: root,
|
||||
onChange: () => {},
|
||||
onError: () => {},
|
||||
getScopedTeamNames: () => new Set(['alpha']),
|
||||
});
|
||||
await registry.start();
|
||||
const targets = latestTargets();
|
||||
await registry.close();
|
||||
|
||||
expect(targets).toContain(path.normalize(root));
|
||||
// scoped team root watched, unscoped team roots not watched
|
||||
expect(targets).toContain(path.normalize(path.join(root, 'alpha')));
|
||||
expect(targets).not.toContain(path.normalize(path.join(root, 'beta')));
|
||||
expect(targets).not.toContain(path.normalize(path.join(root, 'gamma')));
|
||||
// ALL inboxes watched regardless of scope (cross-team delivery)
|
||||
expect(targets).toContain(path.normalize(path.join(root, 'alpha', 'inboxes')));
|
||||
expect(targets).toContain(path.normalize(path.join(root, 'beta', 'inboxes')));
|
||||
expect(targets).toContain(path.normalize(path.join(root, 'gamma', 'inboxes')));
|
||||
});
|
||||
|
||||
it('falls back to watching every team when no scope provider is given', async () => {
|
||||
const registry = new TeamTaskWatchRegistry({
|
||||
kind: 'teams',
|
||||
rootPath: root,
|
||||
onChange: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await registry.start();
|
||||
const targets = latestTargets();
|
||||
await registry.close();
|
||||
|
||||
for (const team of ['alpha', 'beta', 'gamma']) {
|
||||
expect(targets).toContain(path.normalize(path.join(root, team)));
|
||||
expect(targets).toContain(path.normalize(path.join(root, team, 'inboxes')));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to watching every team when the scope provider returns null', async () => {
|
||||
const registry = new TeamTaskWatchRegistry({
|
||||
kind: 'teams',
|
||||
rootPath: root,
|
||||
onChange: () => {},
|
||||
onError: () => {},
|
||||
getScopedTeamNames: () => null,
|
||||
});
|
||||
await registry.start();
|
||||
const targets = latestTargets();
|
||||
await registry.close();
|
||||
|
||||
for (const team of ['alpha', 'beta', 'gamma']) {
|
||||
expect(targets).toContain(path.normalize(path.join(root, team)));
|
||||
}
|
||||
});
|
||||
|
||||
it('scopes task dirs and never adds inboxes (tasks kind)', async () => {
|
||||
const registry = new TeamTaskWatchRegistry({
|
||||
kind: 'tasks',
|
||||
rootPath: root,
|
||||
onChange: () => {},
|
||||
onError: () => {},
|
||||
getScopedTeamNames: () => new Set(['beta']),
|
||||
});
|
||||
await registry.start();
|
||||
const targets = latestTargets();
|
||||
await registry.close();
|
||||
|
||||
expect(targets).toContain(path.normalize(root));
|
||||
expect(targets).toContain(path.normalize(path.join(root, 'beta')));
|
||||
expect(targets).not.toContain(path.normalize(path.join(root, 'alpha')));
|
||||
expect(targets).not.toContain(path.normalize(path.join(root, 'gamma')));
|
||||
// tasks kind never watches inboxes
|
||||
expect(targets).not.toContain(path.normalize(path.join(root, 'beta', 'inboxes')));
|
||||
});
|
||||
|
||||
it('re-resolves scope on requestReconcile (newly scoped team gets watched)', async () => {
|
||||
const scoped = new Set<string>(['alpha']);
|
||||
const registry = new TeamTaskWatchRegistry({
|
||||
kind: 'teams',
|
||||
rootPath: root,
|
||||
onChange: () => {},
|
||||
onError: () => {},
|
||||
getScopedTeamNames: () => scoped,
|
||||
});
|
||||
await registry.start();
|
||||
expect(latestTargets()).not.toContain(path.normalize(path.join(root, 'beta')));
|
||||
|
||||
scoped.add('beta');
|
||||
await registry.requestReconcile();
|
||||
const targets = latestTargets();
|
||||
await registry.close();
|
||||
|
||||
expect(targets).toContain(path.normalize(path.join(root, 'beta')));
|
||||
});
|
||||
});
|
||||
71
test/main/services/infrastructure/teamWatchScope.test.ts
Normal file
71
test/main/services/infrastructure/teamWatchScope.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
computeTeamWatchScope,
|
||||
markTeamEngaged,
|
||||
resetTeamWatchScopeForTests,
|
||||
setAliveTeamsProvider,
|
||||
setTeamWatchScopeChangeListener,
|
||||
} from '../../../../src/main/services/infrastructure/teamWatchScope';
|
||||
|
||||
const FIVE_MIN = 5 * 60_000;
|
||||
|
||||
afterEach(() => {
|
||||
resetTeamWatchScopeForTests();
|
||||
});
|
||||
|
||||
describe('teamWatchScope', () => {
|
||||
it('includes alive teams from the provider', () => {
|
||||
setAliveTeamsProvider(() => ['t-alive']);
|
||||
expect([...computeTeamWatchScope(1000)]).toContain('t-alive');
|
||||
});
|
||||
|
||||
it('includes engaged teams within TTL and prunes after expiry', () => {
|
||||
markTeamEngaged('t-eng', 0);
|
||||
expect(computeTeamWatchScope(FIVE_MIN).has('t-eng')).toBe(true);
|
||||
expect(computeTeamWatchScope(FIVE_MIN + 1).has('t-eng')).toBe(false);
|
||||
// pruning is sticky: it stays out without re-engaging
|
||||
expect(computeTeamWatchScope(FIVE_MIN + 2).has('t-eng')).toBe(false);
|
||||
});
|
||||
|
||||
it('unions alive and engaged teams', () => {
|
||||
setAliveTeamsProvider(() => ['a']);
|
||||
markTeamEngaged('b', 0);
|
||||
const scope = computeTeamWatchScope(1000);
|
||||
expect(scope.has('a')).toBe(true);
|
||||
expect(scope.has('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('notifies the listener only when engagement newly adds to scope', () => {
|
||||
const listener = vi.fn();
|
||||
setTeamWatchScopeChangeListener(listener);
|
||||
markTeamEngaged('x', 0);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
markTeamEngaged('x', 1000); // already in scope -> no extra churn
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not notify when engaging an already-alive (in-scope) team', () => {
|
||||
setAliveTeamsProvider(() => ['y']);
|
||||
const listener = vi.fn();
|
||||
setTeamWatchScopeChangeListener(listener);
|
||||
markTeamEngaged('y', 0);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('survives a throwing alive provider (watcher falls back safely)', () => {
|
||||
setAliveTeamsProvider(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(() => computeTeamWatchScope(0)).not.toThrow();
|
||||
expect([...computeTeamWatchScope(0)]).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores empty team names', () => {
|
||||
const listener = vi.fn();
|
||||
setTeamWatchScopeChangeListener(listener);
|
||||
markTeamEngaged('', 0);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(computeTeamWatchScope(0).size).toBe(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue