diff --git a/src/main/index.ts b/src/main/index.ts index c3069f59..773d7443 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(); }; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 64898634..1fe40349 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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; diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 9e2ff08e..05cfe512 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -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 | 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 | 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 { + 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) { diff --git a/src/main/services/infrastructure/TeamTaskWatchRegistry.ts b/src/main/services/infrastructure/TeamTaskWatchRegistry.ts index 334279b1..1f259402 100644 --- a/src/main/services/infrastructure/TeamTaskWatchRegistry.ts +++ b/src/main/services/infrastructure/TeamTaskWatchRegistry.ts @@ -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 | 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 { + await this.reconcileTargets(); + } + async close(): Promise { this.closed = true; this.generation += 1; @@ -237,6 +262,8 @@ export class TeamTaskWatchRegistry { // emitting user-visible events for those artifacts. const targets = new Set([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'); diff --git a/src/main/services/infrastructure/teamWatchScope.ts b/src/main/services/infrastructure/teamWatchScope.ts new file mode 100644 index 00000000..e553f737 --- /dev/null +++ b/src/main/services/infrastructure/teamWatchScope.ts @@ -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(); +let aliveTeamsProvider: (() => Iterable) | null = null; +let scopeChangeListener: (() => void) | null = null; + +export function setAliveTeamsProvider(provider: (() => Iterable) | null): void { + aliveTeamsProvider = provider; +} + +export function setTeamWatchScopeChangeListener(listener: (() => void) | null): void { + scopeChangeListener = listener; +} + +function collectAliveTeams(scope: Set): 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 { + const scope = new Set(); + 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; +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b5cc9314..2cb284ae 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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); } diff --git a/test/main/services/infrastructure/TeamTaskWatchRegistry.test.ts b/test/main/services/infrastructure/TeamTaskWatchRegistry.test.ts new file mode 100644 index 00000000..2076fc5c --- /dev/null +++ b/test/main/services/infrastructure/TeamTaskWatchRegistry.test.ts @@ -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; +}; + +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(['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'))); + }); +}); diff --git a/test/main/services/infrastructure/teamWatchScope.test.ts b/test/main/services/infrastructure/teamWatchScope.test.ts new file mode 100644 index 00000000..70446671 --- /dev/null +++ b/test/main/services/infrastructure/teamWatchScope.test.ts @@ -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); + }); +});