1049 lines
36 KiB
TypeScript
1049 lines
36 KiB
TypeScript
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<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (reason?: unknown) => void;
|
|
}
|
|
|
|
function createDeferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function buildMember(
|
|
name: string,
|
|
removedAt?: number
|
|
): Pick<ResolvedTeamMember, 'name' | 'removedAt'> {
|
|
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<typeof vi.fn<(...args: unknown[]) => Promise<unknown[]>>>,
|
|
};
|
|
const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never);
|
|
const advisoryByFilePath = new Map<string, MemberRuntimeAdvisory | null>();
|
|
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<void>();
|
|
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<string, { value: MemberRuntimeAdvisory | null; expiresAt: number }>;
|
|
}
|
|
).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<void>[] = [];
|
|
logsFinder.findMemberLogs.mockImplementation(async (_teamName: string, memberName: string) => {
|
|
activeScans += 1;
|
|
maxActiveScans = Math.max(maxActiveScans, activeScans);
|
|
const gate = createDeferred<void>();
|
|
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']);
|
|
});
|
|
});
|