fix: keep watch scope safe on provider errors

This commit is contained in:
777genius 2026-05-30 17:11:31 +03:00
parent 3b7b5dfd75
commit 06036460e9
3 changed files with 23 additions and 18 deletions

View file

@ -29,9 +29,9 @@ export function notifyTeamWatchScopeChanged(): void {
scopeChangeListener?.();
}
function collectAliveTeams(scope: Set<string>): void {
function collectAliveTeams(scope: Set<string>): boolean {
if (!aliveTeamsProvider) {
return;
return true;
}
try {
for (const teamName of aliveTeamsProvider()) {
@ -39,9 +39,11 @@ function collectAliveTeams(scope: Set<string>): void {
scope.add(teamName);
}
}
return true;
} catch {
// A provider failure must never break watching. The watcher treats a thrown
// or empty scope conservatively (inboxes + root stay watched either way).
// A provider failure must never narrow watching. Returning null below is the
// safe fallback: watch every team, matching the original behavior.
return false;
}
}
@ -49,9 +51,11 @@ function collectAliveTeams(scope: Set<string>): void {
* 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> {
export function computeTeamWatchScope(nowMs: number = Date.now()): ReadonlySet<string> | null {
const scope = new Set<string>();
collectAliveTeams(scope);
if (!collectAliveTeams(scope)) {
return null;
}
for (const [teamName, engagedAt] of engagedAtByTeam) {
if (nowMs - engagedAt <= ENGAGED_TTL_MS) {
scope.add(teamName);
@ -71,7 +75,8 @@ export function markTeamEngaged(teamName: string, nowMs: number = Date.now()): v
if (!teamName) {
return;
}
const wasInScope = computeTeamWatchScope(nowMs).has(teamName);
const currentScope = computeTeamWatchScope(nowMs);
const wasInScope = currentScope === null || currentScope.has(teamName);
engagedAtByTeam.set(teamName, nowMs);
if (!wasInScope) {
scopeChangeListener?.();

View file

@ -1323,7 +1323,7 @@ describe('ipc teams handlers', () => {
it('marks created teams engaged before provisioning writes startup artifacts', async () => {
const createTeam = 'created-watch-scope';
provisioningService.createTeam.mockImplementationOnce(async () => {
expect(computeTeamWatchScope().has(createTeam)).toBe(true);
expect(computeTeamWatchScope()?.has(createTeam)).toBe(true);
return { runId: 'run-created-watch-scope' };
});
@ -1334,7 +1334,7 @@ describe('ipc teams handlers', () => {
})) as { success: boolean };
expect(result.success).toBe(true);
expect(computeTeamWatchScope().has(createTeam)).toBe(true);
expect(computeTeamWatchScope()?.has(createTeam)).toBe(true);
});
it('returns cached TEAM_LIST data under active launch pressure without starting another scan', async () => {
@ -4401,7 +4401,7 @@ describe('ipc teams handlers', () => {
})) as { success: boolean };
expect(result.success).toBe(true);
expect(computeTeamWatchScope().has('draft-team')).toBe(true);
expect(computeTeamWatchScope()?.has('draft-team')).toBe(true);
expect(provisioningService.launchTeam).not.toHaveBeenCalled();
expect(provisioningService.createTeam).toHaveBeenCalledWith(
{

View file

@ -18,23 +18,23 @@ afterEach(() => {
describe('teamWatchScope', () => {
it('includes alive teams from the provider', () => {
setAliveTeamsProvider(() => ['t-alive']);
expect([...computeTeamWatchScope(1000)]).toContain('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);
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);
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);
expect(scope?.has('a')).toBe(true);
expect(scope?.has('b')).toBe(true);
});
it('notifies the listener only when engagement newly adds to scope', () => {
@ -66,7 +66,7 @@ describe('teamWatchScope', () => {
throw new Error('boom');
});
expect(() => computeTeamWatchScope(0)).not.toThrow();
expect([...computeTeamWatchScope(0)]).toEqual([]);
expect(computeTeamWatchScope(0)).toBeNull();
});
it('ignores empty team names', () => {
@ -74,6 +74,6 @@ describe('teamWatchScope', () => {
setTeamWatchScopeChangeListener(listener);
markTeamEngaged('', 0);
expect(listener).not.toHaveBeenCalled();
expect(computeTeamWatchScope(0).size).toBe(0);
expect(computeTeamWatchScope(0)?.size).toBe(0);
});
});