import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'fs/promises'; import { TeamMemberRuntimeAdvisoryService } from '../../../../src/main/services/team/TeamMemberRuntimeAdvisoryService'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '../../../../src/shared/types/team'; interface Deferred { promise: Promise; resolve: (value: T) => void; reject: (reason?: unknown) => void; } function createDeferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function buildMember( name: string, removedAt?: number ): Pick { return removedAt == null ? { name } : { name, removedAt }; } function buildRetryingAdvisory(label: string): MemberRuntimeAdvisory { return { kind: 'sdk_retrying', observedAt: '2026-04-09T10:00:00.000Z', retryUntil: '2026-04-09T10:01:00.000Z', retryDelayMs: 60_000, reasonCode: 'backend_error', message: `retry:${label}`, }; } function createStubbedServiceHarness() { const logsFinder = { findMemberLogs: vi.fn(async (_teamName: string, memberName: string) => [ { filePath: `/logs/${memberName}.jsonl` }, ]), findRecentMemberLogFileRefsByMember: undefined as | undefined | ReturnType Promise>>, }; const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never); const advisoryByFilePath = new Map(); const readRecentApiRetryAdvisory = vi .spyOn(service as never, 'readRecentApiRetryAdvisory' as never) .mockImplementation(async (...args: unknown[]) => { const filePath = String(args[0] ?? ''); if (advisoryByFilePath.has(filePath)) { return advisoryByFilePath.get(filePath) ?? null; } return buildRetryingAdvisory(path.basename(filePath, '.jsonl')); }); return { service, logsFinder, advisoryByFilePath, readRecentApiRetryAdvisory }; } describe('TeamMemberRuntimeAdvisoryService', () => { let tmpDir: string | null = null; afterEach(async () => { vi.useRealTimers(); vi.restoreAllMocks(); setClaudeBasePathOverride(null); if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); tmpDir = null; } }); it('returns active sdk retry advisory for a teammate log', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); const teamName = 'signal-ops'; const projectPath = '/Users/test/proj'; const projectId = '-Users-test-proj'; const leadSessionId = 'lead-session'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), `${JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Start' }, })}\n`, 'utf8' ); const nowIso = new Date().toISOString(); await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice.jsonl'), [ JSON.stringify({ timestamp: nowIso, type: 'user', message: { role: 'user', content: 'You are alice, a reviewer on team "signal-ops" (signal-ops).', }, }), JSON.stringify({ timestamp: nowIso, type: 'system', subtype: 'api_error', retryInMs: 45_000, retryAttempt: 1, maxRetries: 10, error: { error: { error: { message: 'Gemini cli backend error: capacity exceeded.', }, }, }, }), ].join('\n') + '\n', 'utf8' ); const service = new TeamMemberRuntimeAdvisoryService(); const advisory = await service.getMemberAdvisory(teamName, 'alice'); expect(advisory).not.toBeNull(); expect(advisory?.kind).toBe('sdk_retrying'); expect(advisory?.reasonCode).toBe('quota_exhausted'); expect(advisory?.message).toContain('capacity exceeded'); }); it.each([ ['rate_limited', 'Provider returned 429 rate limit for this request.'], [ 'rate_limited', 'All credentials for model claude-opus-4-6 are cooling down via provider claude.', ], ['auth_error', 'Authentication failed due to invalid API key.'], [ 'quota_exhausted', 'Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys', ], ['codex_native_timeout', 'Codex native exec timed out after 120000ms.'], ['network_error', 'Fetch failed because the network connection timed out.'], ['filesystem_error', 'ENOSPC: no space left on device, write'], ['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'], ['protocol_proof_missing', 'OpenCode created a reply without the required taskRefs metadata.'], ['backend_error', 'Unexpected backend blew up during request processing.'], ] as const)('classifies %s retry causes from api_error messages', async (expected, message) => { const service = new TeamMemberRuntimeAdvisoryService({} as never); const advisory = (service as any).extractApiRetryAdvisory( JSON.stringify({ type: 'system', subtype: 'api_error', timestamp: '2099-04-09T10:00:00.000Z', retryInMs: 45_000, error: { error: { error: { message, }, }, }, }) ) as MemberRuntimeAdvisory | null; expect(advisory?.reasonCode).toBe(expected); }); it('classifies missing api_error message text as unknown', () => { const service = new TeamMemberRuntimeAdvisoryService({} as never); const advisory = (service as any).extractApiRetryAdvisory( JSON.stringify({ type: 'system', subtype: 'api_error', timestamp: '2099-04-09T10:00:00.000Z', retryInMs: 45_000, }) ) as MemberRuntimeAdvisory | null; expect(advisory?.reasonCode).toBe('unknown'); }); it('keeps terminal API errors visible after retries stop', () => { const service = new TeamMemberRuntimeAdvisoryService({} as never); const observedAt = '2099-04-09T10:00:00.000Z'; const advisory = (service as any).extractApiErrorAdvisory( JSON.stringify({ type: 'assistant', timestamp: observedAt, isApiErrorMessage: true, error: 'unknown', message: { content: [ { type: 'text', text: 'API Error: 500 {"error":{"message":"auth_unavailable: no auth available","type":"server_error"}}', }, ], }, }), Date.parse(observedAt) ) as MemberRuntimeAdvisory | null; expect(advisory).toMatchObject({ kind: 'api_error', reasonCode: 'auth_error', statusCode: 500, }); expect(advisory?.retryUntil).toBeUndefined(); expect(advisory?.message).toContain('auth_unavailable'); }); it('treats Claude Code account access failures as auth errors', () => { const service = new TeamMemberRuntimeAdvisoryService({} as never); const observedAt = '2099-04-09T10:00:00.000Z'; const advisory = (service as any).extractApiErrorAdvisory( JSON.stringify({ type: 'assistant', timestamp: observedAt, isApiErrorMessage: true, error: 'authentication_failed', message: { content: [ { type: 'text', text: 'Your account does not have access to Claude Code. Please run /login.', }, ], }, }), Date.parse(observedAt) ) as MemberRuntimeAdvisory | null; expect(advisory?.kind).toBe('api_error'); expect(advisory?.reasonCode).toBe('auth_error'); }); it('surfaces recent OpenCode prompt delivery provider failures as member advisories', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); const teamName = 'signal-ops'; const laneId = 'secondary:opencode:bob'; const nowIso = new Date().toISOString(); const laneDir = path.join( tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes', encodeURIComponent(laneId) ); await fs.mkdir(laneDir, { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), JSON.stringify({ version: 1, updatedAt: nowIso, lanes: { [laneId]: { laneId, state: 'active', updatedAt: nowIso }, }, }), 'utf8' ); await fs.writeFile( path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), JSON.stringify({ schemaVersion: 1, updatedAt: nowIso, data: [ { id: 'opencode-prompt:test', teamName, memberName: 'bob', laneId, runId: 'run-1', runtimeSessionId: 'ses-1', inboxMessageId: 'msg-1', inboxTimestamp: nowIso, source: 'watcher', messageKind: null, replyRecipient: 'team-lead', actionMode: null, taskRefs: [], payloadHash: 'sha256:test', status: 'failed_terminal', responseState: 'empty_assistant_turn', attempts: 3, maxAttempts: 3, acceptanceUnknown: false, nextAttemptAt: null, lastAttemptAt: nowIso, lastObservedAt: nowIso, acceptedAt: nowIso, respondedAt: null, failedAt: nowIso, inboxReadCommittedAt: null, inboxReadCommitError: null, prePromptCursor: null, postPromptCursor: null, deliveredUserMessageId: 'delivered-1', observedAssistantMessageId: 'assistant-1', observedAssistantPreview: null, observedToolCallNames: [], observedVisibleMessageId: null, visibleReplyMessageId: null, visibleReplyInbox: null, visibleReplyCorrelation: null, lastReason: 'empty_assistant_turn', diagnostics: [ 'OpenCode bridge command timed out', 'Latest assistant message msg_1 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits', 'empty_assistant_turn', ], createdAt: nowIso, updatedAt: nowIso, }, ], }), 'utf8' ); const service = new TeamMemberRuntimeAdvisoryService({ findMemberLogs: vi.fn(async () => { throw new Error('log scan should not be needed when OpenCode ledger has an error'); }), }); const advisory = await service.getMemberAdvisory(teamName, 'bob'); expect(advisory).toMatchObject({ kind: 'api_error', reasonCode: 'quota_exhausted', }); expect(advisory?.message).toContain('Insufficient credits'); expect(advisory?.message).not.toContain('Latest assistant message'); }); it('keeps pending OpenCode free usage exhaustion visible while delivery is unresolved', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-17T21:44:45.000Z')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); const teamName = 'forge-labs'; const laneId = 'secondary:opencode:tom'; const oldIso = '2026-05-17T21:44:34.000Z'; const laneDir = path.join( tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes', encodeURIComponent(laneId) ); await fs.mkdir(laneDir, { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), JSON.stringify({ version: 1, updatedAt: oldIso, lanes: { [laneId]: { laneId, state: 'active', updatedAt: oldIso }, }, }), 'utf8' ); await fs.writeFile( path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), JSON.stringify({ schemaVersion: 1, updatedAt: oldIso, data: [ { id: 'opencode-prompt:free-usage-pending', teamName, memberName: 'tom', laneId, runId: 'run-1', runtimeSessionId: 'ses-1', inboxMessageId: 'msg-1', inboxTimestamp: oldIso, source: 'watcher', messageKind: null, replyRecipient: 'team-lead', actionMode: null, taskRefs: [], payloadHash: 'sha256:test', status: 'accepted', responseState: 'pending', attempts: 2, maxAttempts: 3, acceptanceUnknown: false, nextAttemptAt: '2026-05-17T21:44:37.000Z', lastAttemptAt: oldIso, lastObservedAt: oldIso, acceptedAt: '2026-05-17T21:40:21.000Z', respondedAt: null, failedAt: null, inboxReadCommittedAt: null, inboxReadCommitError: null, prePromptCursor: null, postPromptCursor: null, deliveredUserMessageId: 'msg-opencode-user', observedAssistantMessageId: 'msg-opencode-assistant', observedAssistantPreview: null, observedToolCallNames: [], observedVisibleMessageId: null, visibleReplyMessageId: null, visibleReplyInbox: null, visibleReplyCorrelation: null, lastReason: 'assistant_response_pending', diagnostics: [ 'OpenCode app MCP is connected for message delivery.', 'OpenCode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.', 'OpenCode session status retry - attempt=1 - Free usage exceeded, subscribe to Go https://opencode.ai/go - next=2026-05-18T00:00:00.502Z)', ], createdAt: oldIso, updatedAt: oldIso, }, ], }), 'utf8' ); const service = new TeamMemberRuntimeAdvisoryService({ findMemberLogs: vi.fn(async () => []), }); const advisory = await service.getMemberAdvisory(teamName, 'tom'); expect(advisory).toMatchObject({ kind: 'api_error', reasonCode: 'quota_exhausted', retryUntil: '2026-05-18T00:00:00.502Z', }); expect(advisory?.retryDelayMs).toBeGreaterThan(0); expect(advisory?.message).toContain('Free usage exceeded'); }); it('classifies terminal OpenCode protocol proof failures as warnings, not provider errors', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); const teamName = 'relay-works'; const laneId = 'secondary:opencode:jack'; const nowIso = new Date().toISOString(); const oldIso = new Date(Date.now() - 3 * 60 * 1000).toISOString(); const laneDir = path.join( tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes', encodeURIComponent(laneId) ); await fs.mkdir(laneDir, { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), JSON.stringify({ version: 1, updatedAt: nowIso, lanes: { [laneId]: { laneId, state: 'active', updatedAt: nowIso }, }, }), 'utf8' ); await fs.writeFile( path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), JSON.stringify({ schemaVersion: 1, updatedAt: oldIso, data: [ { id: 'opencode-prompt:proof-missing', teamName, memberName: 'jack', laneId, runId: 'run-1', runtimeSessionId: 'ses-1', inboxMessageId: 'msg-1', inboxTimestamp: oldIso, source: 'watcher', messageKind: null, replyRecipient: 'team-lead', actionMode: null, taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName }], payloadHash: 'sha256:test', status: 'failed_terminal', responseState: 'responded_non_visible_tool', attempts: 3, maxAttempts: 3, acceptanceUnknown: false, nextAttemptAt: null, lastAttemptAt: oldIso, lastObservedAt: oldIso, acceptedAt: oldIso, respondedAt: oldIso, failedAt: oldIso, inboxReadCommittedAt: null, inboxReadCommitError: null, prePromptCursor: null, postPromptCursor: null, deliveredUserMessageId: 'delivered-1', observedAssistantMessageId: 'assistant-1', observedAssistantPreview: null, observedToolCallNames: ['task_get'], observedVisibleMessageId: null, visibleReplyMessageId: null, visibleReplyInbox: null, visibleReplyCorrelation: null, lastReason: 'non_visible_tool_without_task_progress', diagnostics: ['non_visible_tool_without_task_progress'], createdAt: oldIso, updatedAt: oldIso, }, ], }), 'utf8' ); const service = new TeamMemberRuntimeAdvisoryService({ findMemberLogs: vi.fn(async () => []), }); const advisory = await service.getMemberAdvisory(teamName, 'jack'); expect(advisory).toMatchObject({ kind: 'api_error', reasonCode: 'protocol_proof_missing', message: 'OpenCode used tools, but did not create a visible reply or task progress proof.', }); }); it('suppresses stale OpenCode prompt delivery advisories after a visible runtime reply exists', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); const teamName = 'forge-labs'; const laneId = 'secondary:opencode:jack'; const laneDir = path.join( tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes', encodeURIComponent(laneId) ); await fs.mkdir(laneDir, { recursive: true }); await fs.mkdir(path.join(tmpDir, 'teams', teamName, 'inboxes'), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), JSON.stringify({ version: 1, updatedAt: '2026-05-06T18:37:22.058Z', lanes: { [laneId]: { laneId, state: 'active', updatedAt: '2026-05-06T18:37:22.058Z' }, }, }), 'utf8' ); await fs.writeFile( path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), JSON.stringify({ schemaVersion: 1, updatedAt: '2026-05-06T18:37:22.058Z', data: [ { id: 'opencode-prompt:visible-required', teamName, memberName: 'jack', laneId, runId: 'run-1', runtimeSessionId: 'ses-1', inboxMessageId: 'comment-forward-1', inboxTimestamp: '2026-05-06T18:35:46.580Z', source: 'watcher', messageKind: null, replyRecipient: 'team-lead', actionMode: null, taskRefs: [], payloadHash: 'sha256:test', status: 'failed_terminal', responseState: 'responded_non_visible_tool', attempts: 3, maxAttempts: 3, acceptanceUnknown: false, nextAttemptAt: null, lastAttemptAt: '2026-05-06T18:37:22.019Z', lastObservedAt: '2026-05-06T18:37:22.019Z', acceptedAt: '2026-05-06T18:35:58.744Z', respondedAt: '2026-05-06T18:36:38.565Z', failedAt: '2026-05-06T18:37:22.056Z', inboxReadCommittedAt: null, inboxReadCommitError: null, prePromptCursor: null, postPromptCursor: null, deliveredUserMessageId: 'delivered-1', observedAssistantMessageId: 'assistant-1', observedAssistantPreview: null, observedToolCallNames: ['task_get'], observedVisibleMessageId: null, visibleReplyMessageId: null, visibleReplyInbox: null, visibleReplyCorrelation: null, lastReason: 'visible_reply_still_required', diagnostics: [ 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', 'visible_reply_still_required', ], createdAt: '2026-05-06T18:35:46.752Z', updatedAt: '2026-05-06T18:37:22.056Z', }, ], }), 'utf8' ); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'inboxes', 'team-lead.json'), JSON.stringify([ { from: 'jack', to: 'team-lead', text: 'Готово, детали ниже.', timestamp: '2026-05-06T18:43:01.248Z', read: true, relayOfMessageId: 'comment-forward-1', source: 'runtime_delivery', messageId: 'visible-reply-1', }, ]), 'utf8' ); const service = new TeamMemberRuntimeAdvisoryService({ findMemberLogs: vi.fn(async () => []), }); const advisory = await service.getMemberAdvisory(teamName, 'jack'); expect(advisory).toBeNull(); }); it('suppresses stale OpenCode proof advisories after same-task member progress exists', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); const teamName = 'mission-control'; const laneId = 'secondary:opencode:bob'; const taskId = '10d1c1b5-e8be-4dc9-a500-a7e2bc619c9e'; const laneDir = path.join( tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes', encodeURIComponent(laneId) ); await fs.mkdir(laneDir, { recursive: true }); await fs.mkdir(path.join(tmpDir, 'tasks', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), JSON.stringify({ version: 1, updatedAt: '2026-05-08T06:37:47.470Z', lanes: { [laneId]: { laneId, state: 'active', updatedAt: '2026-05-08T06:37:47.470Z' }, }, }), 'utf8' ); await fs.writeFile( path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), JSON.stringify({ schemaVersion: 1, updatedAt: '2026-05-08T06:37:47.470Z', data: [ { id: 'opencode-prompt:task-progress-missing', teamName, memberName: 'bob', laneId, runId: 'run-1', runtimeSessionId: 'ses-1', inboxMessageId: 'task-assignment-1', inboxTimestamp: '2026-05-08T06:36:00.000Z', source: 'watcher', messageKind: null, replyRecipient: 'team-lead', actionMode: null, taskRefs: [{ taskId, displayId: '10d1c1b5', teamName }], payloadHash: 'sha256:test', status: 'failed_terminal', responseState: 'empty_assistant_turn', attempts: 3, maxAttempts: 3, acceptanceUnknown: false, nextAttemptAt: null, lastAttemptAt: '2026-05-08T06:37:30.000Z', lastObservedAt: '2026-05-08T06:37:33.167Z', acceptedAt: '2026-05-08T06:36:29.651Z', respondedAt: '2026-05-08T06:37:33.167Z', failedAt: '2026-05-08T06:37:47.470Z', inboxReadCommittedAt: null, inboxReadCommitError: null, prePromptCursor: null, postPromptCursor: null, deliveredUserMessageId: 'delivered-1', observedAssistantMessageId: 'assistant-1', observedAssistantPreview: null, observedToolCallNames: [], observedVisibleMessageId: null, visibleReplyMessageId: null, visibleReplyInbox: null, visibleReplyCorrelation: null, lastReason: 'empty_assistant_turn', diagnostics: ['empty_assistant_turn'], createdAt: '2026-05-08T06:36:00.000Z', updatedAt: '2026-05-08T06:37:47.470Z', }, ], }), 'utf8' ); await fs.writeFile( path.join(tmpDir, 'tasks', teamName, `${taskId}.json`), JSON.stringify({ id: taskId, displayId: '10d1c1b5', subject: 'Keyboard shortcuts', owner: 'bob', status: 'completed', updatedAt: '2026-05-08T06:40:55.128Z', comments: [ { id: 'progress-comment-1', author: 'bob', text: 'Keyboard shortcuts implemented and verified.', createdAt: '2026-05-08T06:39:40.805Z', type: 'regular', }, ], historyEvents: [ { id: 'status-event-1', type: 'status_changed', from: 'in_progress', to: 'completed', actor: 'bob', timestamp: '2026-05-08T06:40:55.128Z', }, ], }), 'utf8' ); const service = new TeamMemberRuntimeAdvisoryService({ findMemberLogs: vi.fn(async () => []), }); const advisory = await service.getMemberAdvisory(teamName, 'bob'); expect(advisory).toBeNull(); }); it('ignores expired retry advisories', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); const teamName = 'signal-ops'; const projectPath = '/Users/test/proj'; const projectId = '-Users-test-proj'; const leadSessionId = 'lead-session'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), `${JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Start' }, })}\n`, 'utf8' ); await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice.jsonl'), [ JSON.stringify({ timestamp: new Date(Date.now() - 60_000).toISOString(), type: 'user', message: { role: 'user', content: 'You are alice, a reviewer on team "signal-ops" (signal-ops).', }, }), JSON.stringify({ timestamp: new Date(Date.now() - 60_000).toISOString(), type: 'system', subtype: 'api_error', retryInMs: 5_000, retryAttempt: 1, maxRetries: 10, error: { error: { error: { message: 'Old retry window', }, }, }, }), ].join('\n') + '\n', 'utf8' ); const service = new TeamMemberRuntimeAdvisoryService(); await expect(service.getMemberAdvisory(teamName, 'alice')).resolves.toBeNull(); }); it('reuses batch cache within ttl and returns cloned advisory maps', async () => { const { service, logsFinder } = createStubbedServiceHarness(); const members = [buildMember('Alice'), buildMember('Bob')]; const first = await service.getMemberAdvisories('signal-ops', members); const second = await service.getMemberAdvisories('signal-ops', members); expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(2); expect(first).toEqual(second); expect(first).not.toBe(second); expect(first.get('Alice')).not.toBe(second.get('Alice')); }); it('shares one in-flight batch request for concurrent identical calls', async () => { const { service, logsFinder } = createStubbedServiceHarness(); const gate = createDeferred(); logsFinder.findMemberLogs.mockImplementation(async (_teamName: string, memberName: string) => { await gate.promise; return [{ filePath: `/logs/${memberName}.jsonl` }]; }); const firstRequest = service.getMemberAdvisories('signal-ops', [buildMember('Alice')]); const secondRequest = service.getMemberAdvisories('signal-ops', [buildMember('Alice')]); await vi.waitFor(() => expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(1)); gate.resolve(); const [first, second] = await Promise.all([firstRequest, secondRequest]); expect(first).toEqual(second); expect(first).not.toBe(second); }); it('fetches only expired or missing members when building a batch', async () => { const { service, logsFinder } = createStubbedServiceHarness(); await service.getMemberAdvisory('signal-ops', 'Alice'); const memberCache = ( service as unknown as { memberCache: Map; } ).memberCache; memberCache.set('signal-ops::bob', { value: buildRetryingAdvisory('stale-bob'), expiresAt: Date.now() - 1, }); const advisories = await service.getMemberAdvisories('signal-ops', [ buildMember('Alice'), buildMember('Bob'), buildMember('Charlie'), ]); expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual([ 'Alice', 'Bob', 'Charlie', ]); expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob', 'Charlie']); }); it('uses batch member log refs once instead of scanning logs per missing member', async () => { const { service, logsFinder, advisoryByFilePath } = createStubbedServiceHarness(); logsFinder.findRecentMemberLogFileRefsByMember = vi.fn(async () => [ { memberName: 'Alice', filePath: '/logs/alice-new.jsonl', mtimeMs: 300 }, { memberName: 'Alice', filePath: '/logs/alice-old.jsonl', mtimeMs: 100 }, { memberName: 'Bob', filePath: '/logs/bob.jsonl', mtimeMs: 200 }, ]); advisoryByFilePath.set('/logs/alice-new.jsonl', null); advisoryByFilePath.set('/logs/alice-old.jsonl', buildRetryingAdvisory('alice-old')); advisoryByFilePath.set('/logs/bob.jsonl', buildRetryingAdvisory('bob')); const advisories = await service.getMemberAdvisories('signal-ops', [ buildMember('Alice'), buildMember('Bob'), buildMember('Charlie'), ]); expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1); expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledWith( 'signal-ops', ['Alice', 'Bob', 'Charlie'], expect.any(Number) ); expect(logsFinder.findMemberLogs).not.toHaveBeenCalled(); expect(advisories.get('Alice')?.message).toBe('retry:alice-old'); expect(advisories.get('Bob')?.message).toBe('retry:bob'); expect(advisories.has('Charlie')).toBe(false); await service.getMemberAdvisories('signal-ops', [ buildMember('Alice'), buildMember('Bob'), buildMember('Charlie'), ]); expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1); }); it('falls back to per-member log scans when the batch log ref lookup fails', async () => { const { service, logsFinder } = createStubbedServiceHarness(); logsFinder.findRecentMemberLogFileRefsByMember = vi.fn(async () => { throw new Error('batch unavailable'); }); const advisories = await service.getMemberAdvisories('signal-ops', [ buildMember('Alice'), buildMember('Bob'), ]); expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1); expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual(['Alice', 'Bob']); expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob']); }); it('limits concurrent member advisory log scans', async () => { const { service, logsFinder } = createStubbedServiceHarness(); let activeScans = 0; let maxActiveScans = 0; const activeGates: Deferred[] = []; logsFinder.findMemberLogs.mockImplementation(async (_teamName: string, memberName: string) => { activeScans += 1; maxActiveScans = Math.max(maxActiveScans, activeScans); const gate = createDeferred(); activeGates.push(gate); await gate.promise; activeScans -= 1; return [{ filePath: `/logs/${memberName}.jsonl` }]; }); const request = service.getMemberAdvisories('signal-ops', [ buildMember('Alice'), buildMember('Bob'), buildMember('Charlie'), buildMember('Tom'), ]); await vi.waitFor(() => { expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(2); }); expect(maxActiveScans).toBe(2); activeGates.splice(0).forEach((gate) => gate.resolve()); await vi.waitFor(() => { expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(4); }); activeGates.splice(0).forEach((gate) => gate.resolve()); await request; expect(maxActiveScans).toBeLessThanOrEqual(2); }); it('caches null advisory batches and avoids repeated lookups within ttl', async () => { const { service, logsFinder } = createStubbedServiceHarness(); logsFinder.findMemberLogs.mockResolvedValue([]); const first = await service.getMemberAdvisories('signal-ops', [buildMember('ghost')]); const second = await service.getMemberAdvisories('signal-ops', [buildMember('ghost')]); expect(first.size).toBe(0); expect(second.size).toBe(0); expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(1); }); it('excludes removed members from batch signature and result', async () => { const { service, logsFinder } = createStubbedServiceHarness(); const first = await service.getMemberAdvisories('signal-ops', [ buildMember('Alice', Date.now()), buildMember('Bob'), ]); const second = await service.getMemberAdvisories('signal-ops', [buildMember('Bob')]); expect(Array.from(first.keys())).toEqual(['Bob']); expect(Array.from(second.keys())).toEqual(['Bob']); expect(logsFinder.findMemberLogs).toHaveBeenCalledTimes(1); expect(logsFinder.findMemberLogs).toHaveBeenCalledWith('signal-ops', 'Bob', expect.any(Number)); }); it('invalidates team batch cache when member set changes', async () => { const { service, logsFinder } = createStubbedServiceHarness(); const first = await service.getMemberAdvisories('signal-ops', [buildMember('Alice')]); const second = await service.getMemberAdvisories('signal-ops', [ buildMember('Alice'), buildMember('Bob'), ]); expect(Array.from(first.keys())).toEqual(['Alice']); expect(Array.from(second.keys())).toEqual(['Alice', 'Bob']); expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual(['Alice', 'Bob']); }); });