fix: keep watch scope safe on provider errors
This commit is contained in:
parent
3b7b5dfd75
commit
06036460e9
3 changed files with 23 additions and 18 deletions
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue