diff --git a/src/main/index.ts b/src/main/index.ts index 399ca499..d6722651 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1144,6 +1144,9 @@ async function initializeServices(): Promise { teamDataService = new TeamDataService(); teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService); teamProvisioningService = new TeamProvisioningService(); + teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => { + teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName); + }); teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry()); await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) => logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`) diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 8eddcde1..47d912a8 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -1,7 +1,18 @@ import { createLogger } from '@shared/utils/logger'; +import { getTeamsBasePath } from '@main/utils/pathDecoder'; import * as fs from 'fs/promises'; +import { TeamInboxReader } from './TeamInboxReader'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import { + createOpenCodePromptDeliveryLedgerStore, + type OpenCodePromptDeliveryLedgerRecord, +} from './opencode/delivery/OpenCodePromptDeliveryLedger'; +import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; +import { + getOpenCodeLaneScopedRuntimeFilePath, + readOpenCodeRuntimeLaneIndex, +} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types'; @@ -29,11 +40,13 @@ const CACHE_TTL_MS = 30_000; const TAIL_BYTES = 64 * 1024; const BATCH_WARN_MS = 1_000; const ADVISORY_FETCH_CONCURRENCY = 2; +const OPENCODE_DELIVERY_ERROR_LOOKBACK_MS = 30 * 60 * 1000; const QUOTA_EXHAUSTED_TOKENS = [ 'exhausted your capacity', 'capacity exceeded', 'quota exceeded', 'quota exhausted', + 'insufficient credits', ]; const RATE_LIMITED_TOKENS = [ 'rate limit', @@ -70,7 +83,6 @@ const PROVIDER_OVERLOADED_TOKENS = [ 'service unavailable', '503', ]; - const logger = createLogger('Service:TeamMemberRuntimeAdvisory'); interface CachedRuntimeAdvisory { @@ -114,6 +126,43 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory return 'backend_error'; } +function getRecordTimeMs(record: OpenCodePromptDeliveryLedgerRecord): number { + const candidates = [ + record.failedAt, + record.respondedAt, + record.lastObservedAt, + record.updatedAt, + record.createdAt, + ]; + for (const candidate of candidates) { + const time = Date.parse(candidate ?? ''); + if (Number.isFinite(time)) { + return time; + } + } + return 0; +} + +function isTerminalSuccessfulRecord(record: OpenCodePromptDeliveryLedgerRecord): boolean { + return ( + record.status === 'responded' && + Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId) + ); +} + +function isPotentialRuntimeDeliveryError(record: OpenCodePromptDeliveryLedgerRecord): boolean { + if (record.status === 'failed_terminal') { + return true; + } + return ( + record.status !== 'responded' && + (record.responseState === 'session_error' || + record.responseState === 'tool_error' || + record.responseState === 'permission_blocked' || + record.responseState === 'reconcile_failed') + ); +} + async function mapLimit( items: readonly T[], limit: number, @@ -137,8 +186,10 @@ async function mapLimit( } export class TeamMemberRuntimeAdvisoryService { + private readonly inboxReader = new TeamInboxReader(); private readonly memberCache = new Map(); private readonly teamBatchCacheByTeam = new Map(); + private readonly cacheGenerationByTeam = new Map(); private readonly inFlightBatchRequests = new Map< string, Promise> @@ -148,6 +199,23 @@ export class TeamMemberRuntimeAdvisoryService { private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder() ) {} + invalidateMemberAdvisory(teamName: string, memberName: string): void { + const teamKey = this.normalizeToken(teamName); + const memberKey = this.normalizeToken(memberName); + if (!teamKey || !memberKey) { + return; + } + + this.cacheGenerationByTeam.set(teamKey, (this.cacheGenerationByTeam.get(teamKey) ?? 0) + 1); + this.memberCache.delete(`${teamKey}::${memberKey}`); + this.teamBatchCacheByTeam.delete(teamKey); + for (const key of this.inFlightBatchRequests.keys()) { + if (key.startsWith(`${teamKey}::`)) { + this.inFlightBatchRequests.delete(key); + } + } + } + async getMemberAdvisories( teamName: string, members: readonly Pick[] @@ -187,17 +255,21 @@ export class TeamMemberRuntimeAdvisoryService { teamName: string, memberName: string ): Promise { + const teamKey = this.normalizeToken(teamName); const cacheKey = this.getMemberCacheKey(teamName, memberName); const cached = this.memberCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.value ? this.cloneAdvisory(cached.value) : null; } + const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0; const advisory = await this.findRecentMemberAdvisory(teamName, memberName); - this.memberCache.set(cacheKey, { - value: advisory, - expiresAt: Date.now() + CACHE_TTL_MS, - }); + if ((this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart) { + this.memberCache.set(cacheKey, { + value: advisory, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + } return advisory ? this.cloneAdvisory(advisory) : null; } @@ -209,6 +281,7 @@ export class TeamMemberRuntimeAdvisoryService { ): Promise> { const startedAt = performance.now(); const now = Date.now(); + const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0; const result = new Map(); const membersToFetch: string[] = []; let memberCacheHits = 0; @@ -233,23 +306,29 @@ export class TeamMemberRuntimeAdvisoryService { if (membersToFetch.length > 0) { const fetched = await this.findRecentMemberAdvisories(teamName, membersToFetch); const fetchedAt = Date.now(); + const cacheStillCurrent = + (this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart; for (const [memberName, advisory] of fetched) { const normalizedMemberName = this.normalizeToken(memberName); - this.memberCache.set(`${teamKey}::${normalizedMemberName}`, { - value: advisory, - expiresAt: fetchedAt + CACHE_TTL_MS, - }); + if (cacheStillCurrent) { + this.memberCache.set(`${teamKey}::${normalizedMemberName}`, { + value: advisory, + expiresAt: fetchedAt + CACHE_TTL_MS, + }); + } if (advisory) { result.set(normalizedMemberName, this.cloneAdvisory(advisory)); } } } - this.teamBatchCacheByTeam.set(teamKey, { - membersSignature, - value: this.cloneNormalizedAdvisories(result), - expiresAt: Date.now() + CACHE_TTL_MS, - }); + if ((this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart) { + this.teamBatchCacheByTeam.set(teamKey, { + membersSignature, + value: this.cloneNormalizedAdvisories(result), + expiresAt: Date.now() + CACHE_TTL_MS, + }); + } const totalMs = performance.now() - startedAt; if (totalMs >= BATCH_WARN_MS) { @@ -305,6 +384,11 @@ export class TeamMemberRuntimeAdvisoryService { teamName: string, memberName: string ): Promise { + const openCodeAdvisory = await this.findRecentOpenCodeDeliveryAdvisory(teamName, memberName); + if (openCodeAdvisory) { + return openCodeAdvisory; + } + const summaries = await this.logsFinder.findMemberLogs( teamName, memberName, @@ -319,9 +403,33 @@ export class TeamMemberRuntimeAdvisoryService { teamName: string, memberNames: readonly string[] ): Promise { + const openCodeAdvisories = await this.findRecentOpenCodeDeliveryAdvisories( + teamName, + memberNames + ); + const remainingMemberNames = memberNames.filter( + (memberName) => !openCodeAdvisories.has(memberName) + ); + if (remainingMemberNames.length === 0) { + return memberNames.map( + (memberName) => [memberName, openCodeAdvisories.get(memberName) ?? null] as const + ); + } + if (this.logsFinder.findRecentMemberLogFileRefsByMember) { try { - return await this.findRecentMemberAdvisoriesFromBatchRefs(teamName, memberNames); + const logAdvisories = await this.findRecentMemberAdvisoriesFromBatchRefs( + teamName, + remainingMemberNames + ); + const logMap = new Map(logAdvisories); + return memberNames.map( + (memberName) => + [ + memberName, + openCodeAdvisories.get(memberName) ?? logMap.get(memberName) ?? null, + ] as const + ); } catch (error) { logger.warn('batch member runtime advisory log lookup failed; falling back', { teamName, @@ -330,10 +438,226 @@ export class TeamMemberRuntimeAdvisoryService { } } - return mapLimit(memberNames, ADVISORY_FETCH_CONCURRENCY, async (memberName) => { - const advisory = await this.findRecentMemberAdvisory(teamName, memberName); - return [memberName, advisory] as const; + const logAdvisories = await mapLimit( + remainingMemberNames, + ADVISORY_FETCH_CONCURRENCY, + async (memberName) => { + const summaries = await this.logsFinder.findMemberLogs( + teamName, + memberName, + Date.now() - LOOKBACK_MS + ); + return [ + memberName, + await this.findRecentMemberAdvisoryInFiles( + summaries.flatMap((summary) => summary.filePath ?? []) + ), + ] as const; + } + ); + const logMap = new Map(logAdvisories); + return memberNames.map( + (memberName) => + [memberName, openCodeAdvisories.get(memberName) ?? logMap.get(memberName) ?? null] as const + ); + } + + private async findRecentOpenCodeDeliveryAdvisory( + teamName: string, + memberName: string + ): Promise { + const advisories = await this.findRecentOpenCodeDeliveryAdvisories(teamName, [memberName]); + return advisories.get(memberName) ?? null; + } + + private async findRecentOpenCodeDeliveryAdvisories( + teamName: string, + memberNames: readonly string[] + ): Promise> { + const activeMembersByKey = new Map(); + for (const memberName of memberNames) { + const normalized = this.normalizeToken(memberName); + if (normalized && !activeMembersByKey.has(normalized)) { + activeMembersByKey.set(normalized, memberName); + } + } + if (activeMembersByKey.size === 0) { + return new Map(); + } + + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( + () => null + ); + if (!laneIndex) { + return new Map(); + } + + const now = Date.now(); + const recordsByMember = new Map(); + for (const lane of Object.values(laneIndex.lanes)) { + if (lane.state === 'stopped') { + continue; + } + const laneMember = this.getOpenCodeLaneMemberName(lane.laneId); + if (!laneMember || !activeMembersByKey.has(this.normalizeToken(laneMember))) { + continue; + } + const ledger = createOpenCodePromptDeliveryLedgerStore({ + filePath: getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: lane.laneId, + fileName: 'opencode-prompt-delivery-ledger.json', + }), + }); + const records = await ledger.list().catch(() => []); + const existing = recordsByMember.get(this.normalizeToken(laneMember)) ?? []; + existing.push(...records); + recordsByMember.set(this.normalizeToken(laneMember), existing); + } + + const memberKeysWithRecentErrors = new Set(); + for (const [memberKey, records] of recordsByMember) { + if ( + records.some((record) => { + const observedAt = getRecordTimeMs(record); + return ( + isPotentialRuntimeDeliveryError(record) && + Number.isFinite(observedAt) && + now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS + ); + }) + ) { + memberKeysWithRecentErrors.add(memberKey); + } + } + if (memberKeysWithRecentErrors.size === 0) { + return new Map(); + } + + const visibleRuntimeReplyTimes = await this.readVisibleOpenCodeRuntimeDeliveryReplyTimes( + teamName, + memberKeysWithRecentErrors + ); + const result = new Map(); + for (const [memberKey, records] of recordsByMember) { + if (!memberKeysWithRecentErrors.has(memberKey)) { + continue; + } + const originalName = activeMembersByKey.get(memberKey); + const advisory = originalName + ? this.buildOpenCodeDeliveryAdvisoryFromRecords( + originalName, + records, + now, + visibleRuntimeReplyTimes + ) + : null; + if (advisory && originalName) { + result.set(originalName, advisory); + } + } + return result; + } + + private getOpenCodeLaneMemberName(laneId: string): string | null { + const parts = laneId.split(':'); + if (parts.length < 3 || parts[0] !== 'secondary' || parts[1] !== 'opencode') { + return null; + } + return parts.slice(2).join(':').trim() || null; + } + + private buildOpenCodeDeliveryAdvisoryFromRecords( + memberName: string, + records: readonly OpenCodePromptDeliveryLedgerRecord[], + now: number, + visibleRuntimeReplyTimes: ReadonlyMap + ): MemberRuntimeAdvisory | null { + const ordered = records + .slice() + .sort((left, right) => getRecordTimeMs(right) - getRecordTimeMs(left)); + const latestSuccess = ordered.find(isTerminalSuccessfulRecord); + const latestError = ordered.find((record) => { + const observedAt = getRecordTimeMs(record); + return ( + isPotentialRuntimeDeliveryError(record) && + Number.isFinite(observedAt) && + now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS + ); }); + if (!latestError) { + return null; + } + if (latestSuccess && getRecordTimeMs(latestSuccess) > getRecordTimeMs(latestError)) { + return null; + } + if ( + this.hasVisibleRuntimeReplyForOpenCodeDeliveryRecord( + memberName, + latestError, + visibleRuntimeReplyTimes + ) + ) { + return null; + } + + const message = selectOpenCodeRuntimeDeliveryReason(latestError); + if (!message) { + return null; + } + const observedAt = getRecordTimeMs(latestError); + return { + kind: 'api_error', + observedAt: new Date(Number.isFinite(observedAt) ? observedAt : now).toISOString(), + reasonCode: classifyRetryReason(message), + message, + }; + } + + private async readVisibleOpenCodeRuntimeDeliveryReplyTimes( + teamName: string, + activeMemberKeys: ReadonlySet + ): Promise> { + const result = new Map(); + const inboxNames = await this.inboxReader.listInboxNames(teamName).catch(() => []); + await mapLimit(inboxNames, ADVISORY_FETCH_CONCURRENCY, async (inboxName) => { + const messages = await this.inboxReader.getMessagesFor(teamName, inboxName).catch(() => []); + for (const message of messages) { + if (message.source !== 'runtime_delivery' || !message.relayOfMessageId) { + continue; + } + const memberKey = this.normalizeToken(message.from); + if (activeMemberKeys.has(memberKey)) { + const observedAt = Date.parse(message.timestamp); + if (!Number.isFinite(observedAt)) { + continue; + } + const key = this.getOpenCodeRuntimeReplyKey(memberKey, message.relayOfMessageId); + result.set(key, Math.max(result.get(key) ?? 0, observedAt)); + } + } + }); + return result; + } + + private hasVisibleRuntimeReplyForOpenCodeDeliveryRecord( + memberName: string, + record: OpenCodePromptDeliveryLedgerRecord, + visibleRuntimeReplyTimes: ReadonlyMap + ): boolean { + const relayOfMessageId = record.inboxMessageId?.trim(); + if (!relayOfMessageId) { + return false; + } + const replyObservedAt = visibleRuntimeReplyTimes.get( + this.getOpenCodeRuntimeReplyKey(this.normalizeToken(memberName), relayOfMessageId) + ); + return typeof replyObservedAt === 'number' && replyObservedAt > getRecordTimeMs(record); + } + + private getOpenCodeRuntimeReplyKey(memberKey: string, relayOfMessageId: string): string { + return `${memberKey}::${relayOfMessageId.trim()}`; } private async findRecentMemberAdvisoriesFromBatchRefs( diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index 668f529e..b4de64c9 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -85,7 +85,7 @@ function resolveLeadName(config: TeamConfig): string { return lead?.name?.trim() || 'team-lead'; } -function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string { +function resolveSyntheticBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string { const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt; if (typeof raw === 'number' && Number.isFinite(raw)) { return new Date(raw).toISOString(); @@ -99,43 +99,49 @@ function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfi return new Date(0).toISOString(); } -function buildOpenCodeBootstrapDisplayPrompt(config: TeamConfig, member: TeamConfigMember): string { +function buildSyntheticBootstrapDisplayPrompt( + config: TeamConfig, + member: TeamConfigMember +): string { const role = member.role?.trim() || member.agentType?.trim() || 'team member'; const displayName = config.description?.trim() || config.name; - const providerLine = '\nProvider override for this teammate: opencode.'; + const providerId = member.providerId?.trim(); + const providerLine = providerId ? `\nProvider override for this teammate: ${providerId}.` : ''; const modelLine = member.model?.trim() ? `\nModel override for this teammate: ${member.model.trim()}.` : ''; + const runtimeProviderField = providerId === 'opencode' ? ', runtimeProvider: "opencode"' : ''; return `You are ${member.name}, a ${role} on team "${displayName}" (${config.name}).${providerLine}${modelLine} The team has already been created and you are being attached as a persistent teammate. Your FIRST action: call MCP tool member_briefing on the "agent-teams" server with: -{ teamName: "${config.name}", memberName: "${member.name}", runtimeProvider: "opencode" } +{ teamName: "${config.name}", memberName: "${member.name}"${runtimeProviderField} } Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a delegated helper for this step. After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`; } -function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessage[] { +function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] { const members = Array.isArray(config.members) ? config.members : []; const leadName = resolveLeadName(config); + const normalizedLeadName = leadName.trim().toLowerCase(); return members .filter( (member) => member && member.name?.trim() && - member.providerId === 'opencode' && + member.name.trim().toLowerCase() !== normalizedLeadName && member.removedAt == null && (member as { isActive?: unknown }).isActive !== false ) .map((member) => ({ from: leadName, to: member.name, - text: buildOpenCodeBootstrapDisplayPrompt(config, member), - timestamp: resolveOpenCodeBootstrapTimestamp(config, member), + text: buildSyntheticBootstrapDisplayPrompt(config, member), + timestamp: resolveSyntheticBootstrapTimestamp(config, member), read: true, source: 'system_notification' as const, - messageId: `opencode-bootstrap-start:${config.name}:${member.name}`, + messageId: `bootstrap-start:${config.name}:${member.name}`, })); } @@ -503,7 +509,7 @@ export class TeamMessageFeedService { const sourceMs = Date.now() - sourceStartedAt; const normalizeStartedAt = Date.now(); - const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config); + const syntheticMessages = buildSyntheticBootstrapMessages(config); let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter( isVisibleTeamMessage ); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 26e3311c..fac7371a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -163,6 +163,7 @@ import { type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryStatus, } from './opencode/delivery/OpenCodePromptDeliveryLedger'; +import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import { decideOpenCodePromptDeliveryRepair, type OpenCodePromptDeliveryHardFailureKind, @@ -5225,6 +5226,8 @@ export class TeamProvisioningService { private static readonly MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500; private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000; private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000; + private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000; + private static readonly OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS = 24 * 60 * 60_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -5271,6 +5274,8 @@ export class TeamProvisioningService { Promise >(); private readonly openCodePromptDeliveryWatchdogTimers = new Map(); + private readonly openCodeRuntimeDeliveryAdvisoryEventSentAt = new Map(); + private readonly openCodeRuntimeDeliveryLeadNoticeSentAt = new Map(); private readonly openCodePromptDeliveryWatchdogQueue: { teamName: string; run: () => Promise; @@ -5340,6 +5345,9 @@ export class TeamProvisioningService { string, Promise >(); + private memberRuntimeAdvisoryInvalidator: + | ((teamName: string, memberName: string) => void) + | null = null; private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -5584,6 +5592,12 @@ export class TeamProvisioningService { this.runtimeAdapterRegistry = registry; } + setMemberRuntimeAdvisoryInvalidator( + invalidator: ((teamName: string, memberName: string) => void) | null + ): void { + this.memberRuntimeAdvisoryInvalidator = invalidator; + } + setCrossTeamSender( sender: | ((request: { @@ -7509,6 +7523,159 @@ export class TeamProvisioningService { ...extra, }) ); + if ( + event === 'opencode_prompt_delivery_terminal_failure' && + record.status === 'failed_terminal' + ) { + void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => { + logger.warn( + `[${record.teamName}] Failed to fire OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}` + ); + }); + } + } + + private async fireOpenCodeRuntimeDeliveryErrorNotification( + record: OpenCodePromptDeliveryLedgerRecord + ): Promise { + const reason = this.selectOpenCodeRuntimeDeliveryNotificationReason(record); + if (!reason) { + return; + } + + const config = await this.readConfigSnapshot(record.teamName).catch(() => null); + const teamDisplayName = config?.name?.trim() || record.teamName; + const taskLabel = record.taskRefs[0]?.displayId?.trim() + ? `#${record.taskRefs[0].displayId.trim()}` + : null; + const context = taskLabel ? ` while handling ${taskLabel}` : ''; + const body = `Team ${teamDisplayName}: @${record.memberName} hit an OpenCode runtime delivery error${context}. ${reason}`; + + try { + await NotificationManager.getInstance().addTeamNotification({ + teamEventType: 'api_error', + teamName: record.teamName, + teamDisplayName, + from: record.memberName, + summary: taskLabel + ? `OpenCode runtime error ${taskLabel}` + : 'OpenCode runtime delivery error', + body, + dedupeKey: `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}`, + target: { + kind: 'member', + teamName: record.teamName, + memberName: record.memberName, + focus: 'messages', + }, + projectPath: config?.projectPath, + }); + } catch (error) { + logger.warn( + `[${record.teamName}] Failed to store OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}` + ); + } + + this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(record); + + await this.notifyLeadAboutOpenCodeRuntimeDeliveryError({ + record, + reason, + taskLabel, + }); + } + + private emitOpenCodeRuntimeDeliveryAdvisoryEvent( + record: OpenCodePromptDeliveryLedgerRecord + ): void { + try { + this.memberRuntimeAdvisoryInvalidator?.(record.teamName, record.memberName); + } catch (error) { + logger.warn( + `[${record.teamName}] Failed to invalidate OpenCode runtime advisory cache for ${record.memberName}: ${getErrorMessage(error)}` + ); + } + + const eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}`; + const now = Date.now(); + this.pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now); + if (this.openCodeRuntimeDeliveryAdvisoryEventSentAt.has(eventKey)) { + return; + } + + try { + this.teamChangeEmitter?.({ + type: 'member-advisory', + teamName: record.teamName, + detail: `opencode-runtime-delivery-error:${record.memberName}:${record.id}`, + }); + this.openCodeRuntimeDeliveryAdvisoryEventSentAt.set(eventKey, now); + } catch (error) { + logger.warn( + `[${record.teamName}] Failed to emit member advisory refresh for ${record.memberName}: ${getErrorMessage(error)}` + ); + } + } + + private pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now: number): void { + const ttlMs = TeamProvisioningService.OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS; + for (const [key, sentAt] of this.openCodeRuntimeDeliveryAdvisoryEventSentAt) { + if (now - sentAt > ttlMs) { + this.openCodeRuntimeDeliveryAdvisoryEventSentAt.delete(key); + } + } + } + + private async notifyLeadAboutOpenCodeRuntimeDeliveryError(input: { + record: OpenCodePromptDeliveryLedgerRecord; + reason: string; + taskLabel: string | null; + }): Promise { + const runId = this.getAliveRunId(input.record.teamName); + const run = runId ? this.runs.get(runId) : null; + if (!run || run.processKilled || run.cancelRequested) { + return; + } + + const noticeKey = `opencode_runtime_delivery_error:${input.record.teamName}:${input.record.memberName}:${input.record.id}`; + const now = Date.now(); + this.pruneOpenCodeRuntimeDeliveryLeadNoticeDedupe(now); + if (this.openCodeRuntimeDeliveryLeadNoticeSentAt.has(noticeKey)) { + return; + } + + this.openCodeRuntimeDeliveryLeadNoticeSentAt.set(noticeKey, now); + const taskContext = input.taskLabel ? ` while handling ${input.taskLabel}` : ''; + const message = [ + `System notice: OpenCode teammate @${input.record.memberName} hit a runtime delivery error${taskContext}.`, + `Reason: ${input.reason}`, + `Treat @${input.record.memberName} as unavailable for that work until retry or restart succeeds.`, + `Do not message the human user solely because of this notice unless user action is required.`, + ].join(' '); + + try { + await this.sendMessageToRun(run, message); + } catch (error) { + this.openCodeRuntimeDeliveryLeadNoticeSentAt.delete(noticeKey); + logger.warn( + `[${input.record.teamName}] Failed to notify lead about OpenCode runtime delivery error for ${input.record.memberName}: ${getErrorMessage(error)}` + ); + } + } + + private pruneOpenCodeRuntimeDeliveryLeadNoticeDedupe(now: number): void { + const ttlMs = TeamProvisioningService.OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS; + for (const [key, sentAt] of this.openCodeRuntimeDeliveryLeadNoticeSentAt) { + if (now - sentAt > ttlMs) { + this.openCodeRuntimeDeliveryLeadNoticeSentAt.delete(key); + } + } + } + + private selectOpenCodeRuntimeDeliveryNotificationReason( + record: OpenCodePromptDeliveryLedgerRecord + ): string | null { + return selectOpenCodeRuntimeDeliveryReason(record); } async scanOpenCodePromptDeliveryWatchdog(teamName: string): Promise { diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts new file mode 100644 index 00000000..d66cf4a6 --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -0,0 +1,96 @@ +import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger'; + +const SECRET_VALUE_PATTERN = + /\b(?:sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]*api[_-]?key[A-Za-z0-9_-]*[=:]\s*['"]?[^'"\s]+|authorization:\s*bearer\s+[^'"\s]+)\b/gi; + +const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [ + 'opencode app mcp was reattached before message delivery', + 'reattached stale opencode app mcp server', + 'opencode session reconcile skipped because the stored session is stale', + 'recreated opencode session before message delivery', + 'opencode message delivery observe bridge failed', + 'opencode bridge command timed out', + 'opencode bootstrap mcp did not complete required tools before assistant response', + 'existing app mcp config does not expose environment', + 'empty_assistant_turn', + 'visible_reply_still_required', + 'prompt_delivered_no_assistant_message', + 'plain_text_ack_only_still_requires_answer', + 'visible_reply_ack_only_still_requires_answer', + 'visible_reply_destination_not_found_yet', + 'visible_reply_missing_relayofmessageid', +] as const; + +export function normalizeOpenCodeRuntimeDeliveryDiagnostic( + message: string | null | undefined +): string | null { + const normalized = message + ?.replace(/\s+/g, ' ') + .trim() + .replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '') + .replace(/^APIError\s*[-:]\s*/i, '') + .replace(SECRET_VALUE_PATTERN, '[redacted]'); + return normalized && normalized.length > 0 ? normalized : null; +} + +export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boolean { + const normalized = message.trim().toLowerCase(); + return GENERIC_DELIVERY_DIAGNOSTIC_TOKENS.some((token) => normalized.includes(token)); +} + +export function selectOpenCodeRuntimeDeliveryReason( + record: OpenCodePromptDeliveryLedgerRecord +): string | null { + const candidates = [...record.diagnostics.slice().reverse(), record.lastReason]; + const normalized = candidates.flatMap((candidate) => { + const message = normalizeOpenCodeRuntimeDeliveryDiagnostic(candidate); + return message ? [message] : []; + }); + const specific = normalized.find( + (message) => !isGenericOpenCodeRuntimeDeliveryDiagnostic(message) + ); + if (specific) { + return boundOpenCodeRuntimeDeliveryReason(specific); + } + + const fallback = getOpenCodeRuntimeDeliveryStateFallback(record); + if (fallback) { + return fallback; + } + + return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null; +} + +function getOpenCodeRuntimeDeliveryStateFallback( + record: OpenCodePromptDeliveryLedgerRecord +): string | null { + const state = record.responseState?.trim(); + const reason = record.lastReason?.trim(); + if (state === 'empty_assistant_turn' || reason === 'empty_assistant_turn') { + return 'OpenCode returned an empty assistant turn.'; + } + if ( + reason === 'visible_reply_still_required' || + reason === 'visible_reply_ack_only_still_requires_answer' || + reason === 'plain_text_ack_only_still_requires_answer' + ) { + return 'OpenCode responded, but did not create a visible message_send reply.'; + } + if ( + state === 'prompt_delivered_no_assistant_message' || + reason === 'prompt_delivered_no_assistant_message' + ) { + return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; + } + if ( + reason === 'visible_reply_destination_not_found_yet' || + reason === 'visible_reply_missing_relayOfMessageId' + ) { + return 'OpenCode created a reply without the required relayOfMessageId correlation.'; + } + return null; +} + +function boundOpenCodeRuntimeDeliveryReason(reason: string): string { + return reason.length > 500 ? `${reason.slice(0, 497).trimEnd()}...` : reason; +} diff --git a/src/renderer/components/dashboard/WebPreviewBanner.tsx b/src/renderer/components/dashboard/WebPreviewBanner.tsx index 2b4467c3..1c7a7cdd 100644 --- a/src/renderer/components/dashboard/WebPreviewBanner.tsx +++ b/src/renderer/components/dashboard/WebPreviewBanner.tsx @@ -16,10 +16,12 @@ export const WebPreviewBanner = (): React.JSX.Element | null => { >
-

Web version is still in development

+

+ Open the desktop app for full functionality +

- Some desktop features are not available in the browser yet. Project actions, integrations, - and live status data may be limited or not work as expected. + The browser version is still in development. Project actions, integrations, and live + status updates may be limited here. Use the desktop app to access all features reliably.

diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index ae98920c..2f99e8b0 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -64,9 +64,9 @@ export const TaskLogsPanel = ({ const pulseTimerRef = useRef | null>(null); const countReloadTimerRef = useRef | null>(null); const countRequestSeqRef = useRef(0); - const taskLogTrackingEnabled = - hasOpenedContent && task.status === 'in_progress' && availableTabs.includes('stream'); - const taskLogSummaryEnabled = hasOpenedContent && availableTabs.includes('stream'); + const hasTaskLogStream = availableTabs.includes('stream'); + const taskLogActivityTrackingEnabled = task.status === 'in_progress' && hasTaskLogStream; + const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream; useEffect(() => { setActiveTab(defaultTab); @@ -133,7 +133,7 @@ export const TaskLogsPanel = ({ }, [task.id, taskLogSummaryEnabled, teamName]); useEffect(() => { - if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) { + if (!taskLogActivityTrackingEnabled || !api.teams.setTaskLogStreamTracking) { return; } @@ -143,10 +143,10 @@ export const TaskLogsPanel = ({ () => undefined ); }; - }, [taskLogTrackingEnabled, teamName]); + }, [taskLogActivityTrackingEnabled, teamName]); useEffect(() => { - if (!taskLogTrackingEnabled) { + if (!taskLogActivityTrackingEnabled) { if (pulseTimerRef.current) { clearTimeout(pulseTimerRef.current); pulseTimerRef.current = null; @@ -160,7 +160,7 @@ export const TaskLogsPanel = ({ } const scheduleCountReload = (): void => { - if (!api.teams.getTaskLogStreamSummary) { + if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) { return; } if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { @@ -230,7 +230,7 @@ export const TaskLogsPanel = ({ unsubscribe(); } }; - }, [task.id, taskLogTrackingEnabled, teamName]); + }, [task.id, taskLogActivityTrackingEnabled, taskLogSummaryEnabled, teamName]); return ( ([ 'lead-message', 'lead-context', 'lead-activity', + 'member-advisory', 'process', 'member-spawn', ]); @@ -268,6 +269,7 @@ export function initializeNotificationListeners(): () => void { let teamRefreshTimers = new Map>(); let teamMessageRefreshTimers = new Map>(); let teamPresenceRefreshTimers = new Map>(); + let memberAdvisorySafetyRefreshTimers = new Map>(); let memberSpawnRefreshTimers = new Map>(); let teamAgentRuntimeRefreshTimers = new Map>(); let toolActivityTimers = new Map>(); @@ -1610,6 +1612,75 @@ export function initializeNotificationListeners(): () => void { return; } + if (event.type === 'member-advisory') { + if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { + return; + } + cancelProcessLiteStructuralReconcile(event.teamName); + const eventReason = buildTeamChangeFanoutReason(event.type); + const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName; + const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName; + const existingSafetyTimer = memberAdvisorySafetyRefreshTimers.get(event.teamName); + if (existingSafetyTimer) { + clearTimeout(existingSafetyTimer); + } + memberAdvisorySafetyRefreshTimers.set( + event.teamName, + setTimeout(() => { + memberAdvisorySafetyRefreshTimers.delete(event.teamName); + if (!isTeamVisibleInAnyPane(event.teamName)) { + return; + } + const current = useStore.getState(); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: `${eventReason}:safety`, + operation: 'refreshTeamData', + eventType: event.type, + selected: current.selectedTeamName === event.teamName, + visible: true, + activeTab: getFocusedVisibleTeamName() === event.teamName, + }); + void current.refreshTeamData(event.teamName); + }, TEAM_REFRESH_THROTTLE_MS + 250) + ); + const existingDetailTimer = teamRefreshTimers.get(event.teamName); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: existingDetailTimer ? 'coalesced' : 'scheduled', + reason: eventReason, + operation: 'refreshTeamData', + eventType: event.type, + selected: selectedForRefresh, + visible: true, + activeTab: activeTabForRefresh, + }); + if (existingDetailTimer) { + return; + } + const timer = setTimeout(() => { + teamRefreshTimers.delete(event.teamName); + const current = useStore.getState(); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: eventReason, + operation: 'refreshTeamData', + eventType: event.type, + selected: current.selectedTeamName === event.teamName, + visible: isTeamVisibleInAnyPane(event.teamName), + activeTab: getFocusedVisibleTeamName() === event.teamName, + }); + void current.refreshTeamData(event.teamName, { withDedup: true }); + }, TEAM_REFRESH_THROTTLE_MS); + teamRefreshTimers.set(event.teamName, timer); + return; + } + if (event.type === 'log-source-change') { if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { return; @@ -1791,6 +1862,8 @@ export function initializeNotificationListeners(): () => void { teamMessageRefreshTimers = new Map(); for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t); teamPresenceRefreshTimers = new Map(); + for (const t of memberAdvisorySafetyRefreshTimers.values()) clearTimeout(t); + memberAdvisorySafetyRefreshTimers = new Map(); for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t); memberSpawnRefreshTimers = new Map(); for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t); diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 690b4725..70af94e2 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -321,10 +321,55 @@ function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined) } function appendRuntimeAdvisoryRawMessage(base: string, message: string | undefined): string { - const trimmed = message?.trim(); + const trimmed = formatRuntimeAdvisoryDisplayMessage(message); return trimmed ? `${base}\n\n${trimmed}` : base; } +function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined): boolean { + const displayMessage = formatRuntimeAdvisoryDisplayMessage(message); + return ( + displayMessage.startsWith('OpenCode runtime delivery') || + displayMessage.startsWith('OpenCode returned an empty assistant turn') || + displayMessage.startsWith('OpenCode accepted the prompt') || + displayMessage.startsWith('OpenCode responded, but did not create') || + displayMessage.startsWith('OpenCode created a reply without') + ); +} + +function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): string { + const trimmed = message?.trim(); + if (!trimmed) { + return ''; + } + if (trimmed === 'empty_assistant_turn') { + return 'OpenCode returned an empty assistant turn.'; + } + if (trimmed === 'prompt_delivered_no_assistant_message') { + return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; + } + if ( + trimmed === 'visible_reply_still_required' || + trimmed === 'visible_reply_ack_only_still_requires_answer' || + trimmed === 'plain_text_ack_only_still_requires_answer' + ) { + return 'OpenCode responded, but did not create a visible message_send reply.'; + } + if ( + trimmed === 'visible_reply_destination_not_found_yet' || + trimmed === 'visible_reply_missing_relayOfMessageId' + ) { + return 'OpenCode created a reply without the required relayOfMessageId correlation.'; + } + if ( + trimmed.startsWith( + 'OpenCode bootstrap MCP did not complete required tools before assistant response:' + ) + ) { + return 'OpenCode runtime delivery did not complete.'; + } + return trimmed; +} + function formatRuntimeAdvisoryBaseLabel( advisory: MemberRuntimeAdvisory, providerId: TeamProviderId | undefined @@ -346,6 +391,12 @@ function formatRuntimeAdvisoryBaseLabel( return providerLabel ? `${providerLabel} overload` : 'Provider overload'; case 'backend_error': case 'unknown': + if ( + providerId === 'opencode' && + isOpenCodeRuntimeDeliveryAdvisoryMessage(advisory.message) + ) { + return 'OpenCode delivery error'; + } return providerLabel ? `${providerLabel} API error` : 'API error'; default: return 'API error'; @@ -409,6 +460,15 @@ function formatRuntimeAdvisoryTitle( ); case 'backend_error': case 'unknown': + if ( + providerId === 'opencode' && + isOpenCodeRuntimeDeliveryAdvisoryMessage(advisory.message) + ) { + return appendRuntimeAdvisoryRawMessage( + 'OpenCode runtime delivery error.', + advisory.message + ); + } return appendRuntimeAdvisoryRawMessage( `${providerLabel ?? 'Provider'} API error.`, advisory.message diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 3916c01c..37dd17b3 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -33,6 +33,19 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde if (normalized === 'prompt_delivered_no_assistant_message') { return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; } + if ( + normalized === 'visible_reply_still_required' || + normalized === 'visible_reply_ack_only_still_requires_answer' || + normalized === 'plain_text_ack_only_still_requires_answer' + ) { + return 'OpenCode responded, but did not create a visible message_send reply.'; + } + if ( + normalized === 'visible_reply_destination_not_found_yet' || + normalized === 'visible_reply_missing_relayOfMessageId' + ) { + return 'OpenCode created a reply without the required relayOfMessageId correlation.'; + } return ''; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index b11ac9fc..e42bcf6e 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1201,6 +1201,7 @@ export interface TeamChangeEvent { | 'lead-message' | 'tool-activity' | 'member-turn-settled' + | 'member-advisory' | 'process' | 'member-spawn'; teamName: string; diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts new file mode 100644 index 00000000..4ba27b7f --- /dev/null +++ b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { selectOpenCodeRuntimeDeliveryReason } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; + +import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; + +function record( + input: Partial +): OpenCodePromptDeliveryLedgerRecord { + return { + id: 'opencode-prompt:test', + teamName: 'forge-labs', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'msg-1', + inboxTimestamp: '2026-05-06T18:31:36.478Z', + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'not_observed', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: null, + lastObservedAt: null, + acceptedAt: null, + respondedAt: null, + failedAt: '2026-05-06T18:33:42.896Z', + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: null, + observedAssistantMessageId: null, + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: null, + diagnostics: [], + createdAt: '2026-05-06T18:31:36.636Z', + updatedAt: '2026-05-06T18:33:42.896Z', + ...input, + }; +} + +describe('OpenCodeRuntimeDeliveryDiagnostics', () => { + it('skips internal bootstrap MCP diagnostics when a provider error is available', () => { + const reason = selectOpenCodeRuntimeDeliveryReason( + record({ + responseState: 'empty_assistant_turn', + lastReason: 'empty_assistant_turn', + diagnostics: [ + 'OpenCode app MCP was reattached before message delivery.', + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + 'Latest assistant message msg_1 failed with APIError - Insufficient credits. Add more credits.', + 'empty_assistant_turn', + ], + }) + ); + + expect(reason).toBe('Insufficient credits. Add more credits.'); + }); + + it('falls back to empty assistant turn when diagnostics are only internal noise', () => { + const reason = selectOpenCodeRuntimeDeliveryReason( + record({ + responseState: 'empty_assistant_turn', + lastReason: 'empty_assistant_turn', + diagnostics: [ + 'OpenCode bridge command timed out', + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + 'empty_assistant_turn', + ], + }) + ); + + expect(reason).toBe('OpenCode returned an empty assistant turn.'); + }); + + it('maps missing visible reply proof to a readable protocol error', () => { + const reason = selectOpenCodeRuntimeDeliveryReason( + record({ + responseState: 'responded_non_visible_tool', + 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', + ], + }) + ); + + expect(reason).toBe('OpenCode responded, but did not create a visible message_send reply.'); + }); + + it('never exposes only internal generic bootstrap diagnostics as the user-facing reason', () => { + const reason = selectOpenCodeRuntimeDeliveryReason( + record({ + diagnostics: [ + 'OpenCode app MCP was reattached before message delivery.', + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + ], + }) + ); + + expect(reason).toBe('OpenCode runtime delivery did not complete.'); + }); +}); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index a6a2d4a8..847b0dfc 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -257,6 +257,214 @@ describe('TeamMemberRuntimeAdvisoryService', () => { 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('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('ignores expired retry advisories', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts index b3a37cb8..8692e530 100644 --- a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts @@ -370,11 +370,14 @@ describe('TaskLogsPanel', () => { expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false); }); - it('defers Task Log Stream work while collapsed, then starts tracking after first open', async () => { + it('tracks header activity while collapsed but defers Task Log Stream content until first open', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.useFakeTimers(); const activityStates: boolean[] = []; + const onTaskLogActivityChange = (isActive: boolean): void => { + activityStates.push(isActive); + }; let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null; apiState.onTeamChange.mockImplementation((callback) => { handler = callback; @@ -393,7 +396,7 @@ describe('TaskLogsPanel', () => { teamName: 'demo', task: makeTask(), isOpen: false, - onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive), + onTaskLogActivityChange, }) ); await flushMicrotasks(); @@ -402,18 +405,33 @@ describe('TaskLogsPanel', () => { expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull(); expect(taskLogStreamProps.calls).toHaveLength(0); expect(apiState.getTaskLogStreamSummary).not.toHaveBeenCalled(); - expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled(); - expect(apiState.onTeamChange).not.toHaveBeenCalled(); - expect(handler).toBeNull(); + expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true); + expect(apiState.onTeamChange).toHaveBeenCalledTimes(1); + expect(handler).toBeTypeOf('function'); expect(activityStates).toEqual([false]); + await act(async () => { + handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false, true]); + expect(apiState.getTaskLogStreamSummary).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1800); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false, true, false]); + await act(async () => { root.render( React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask(), isOpen: true, - onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive), + onTaskLogActivityChange, }) ); await flushMicrotasks(); @@ -422,7 +440,6 @@ describe('TaskLogsPanel', () => { expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull(); expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledWith('demo', 'task-1'); - expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true); expect(handler).toBeTypeOf('function'); await act(async () => { @@ -430,14 +447,14 @@ describe('TaskLogsPanel', () => { await flushMicrotasks(); }); - expect(activityStates).toEqual([false, false, true]); + expect(activityStates).toEqual([false, true, false, true]); await act(async () => { vi.advanceTimersByTime(1800); await flushMicrotasks(); }); - expect(activityStates).toEqual([false, false, true, false]); + expect(activityStates).toEqual([false, true, false, true, false]); await act(async () => { root.unmount(); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 8cd3f956..651fad05 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -699,6 +699,39 @@ describe('memberHelpers spawn-aware presence', () => { ).toContain('Anthropic authentication error'); }); + it('formats raw OpenCode protocol advisory reasons before showing them in titles', () => { + const advisory = { + kind: 'api_error' as const, + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'backend_error' as const, + message: 'visible_reply_still_required', + }; + + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode delivery error'); + + const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); + + expect(title).toContain('OpenCode runtime delivery error.'); + expect(title).toContain('OpenCode responded, but did not create a visible message_send reply.'); + expect(title).not.toContain('visible_reply_still_required'); + }); + + it('hides internal OpenCode bootstrap MCP diagnostics from advisory titles', () => { + const title = getMemberRuntimeAdvisoryTitle( + { + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'backend_error', + message: + 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', + }, + 'opencode' + ); + + expect(title).toContain('OpenCode runtime delivery did not complete.'); + expect(title).not.toContain('runtime_bootstrap_checkin'); + }); + it('renders Codex native timeout separately from network errors', () => { const advisory = { kind: 'api_error' as const, diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index f4f3be51..763980e0 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -52,4 +52,29 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { reason: 'prompt_delivered_no_assistant_message', }); }); + + it('surfaces missing visible reply proof as a readable failure', () => { + const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ + deliveredToInbox: true, + messageId: 'msg-visible-required', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'failed_terminal', + reason: 'visible_reply_still_required', + diagnostics: ['visible_reply_still_required'], + }, + }); + + expect(diagnostics.warning).toBe( + 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode responded, but did not create a visible message_send reply.' + ); + expect(diagnostics.debugDetails).toMatchObject({ + responseState: 'responded_non_visible_tool', + reason: 'visible_reply_still_required', + }); + }); });