feat(opencode): surface runtime delivery diagnostics

This commit is contained in:
777genius 2026-05-06 21:56:47 +03:00
parent 3992ab0dab
commit f57b1bf18b
16 changed files with 1194 additions and 49 deletions

View file

@ -1144,6 +1144,9 @@ async function initializeServices(): Promise<void> {
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)}`)

View file

@ -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<T, R>(
items: readonly T[],
limit: number,
@ -137,8 +186,10 @@ async function mapLimit<T, R>(
}
export class TeamMemberRuntimeAdvisoryService {
private readonly inboxReader = new TeamInboxReader();
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
private readonly cacheGenerationByTeam = new Map<string, number>();
private readonly inFlightBatchRequests = new Map<
string,
Promise<Map<string, MemberRuntimeAdvisory>>
@ -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<ResolvedTeamMember, 'name' | 'removedAt'>[]
@ -187,17 +255,21 @@ export class TeamMemberRuntimeAdvisoryService {
teamName: string,
memberName: string
): Promise<MemberRuntimeAdvisory | null> {
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<Map<string, MemberRuntimeAdvisory>> {
const startedAt = performance.now();
const now = Date.now();
const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0;
const result = new Map<string, MemberRuntimeAdvisory>();
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<MemberRuntimeAdvisory | null> {
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<readonly (readonly [string, MemberRuntimeAdvisory | null])[]> {
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<MemberRuntimeAdvisory | null> {
const advisories = await this.findRecentOpenCodeDeliveryAdvisories(teamName, [memberName]);
return advisories.get(memberName) ?? null;
}
private async findRecentOpenCodeDeliveryAdvisories(
teamName: string,
memberNames: readonly string[]
): Promise<Map<string, MemberRuntimeAdvisory>> {
const activeMembersByKey = new Map<string, string>();
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<string, OpenCodePromptDeliveryLedgerRecord[]>();
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<string>();
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<string, MemberRuntimeAdvisory>();
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<string, number>
): 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<string>
): Promise<Map<string, number>> {
const result = new Map<string, number>();
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<string, number>
): 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(

View file

@ -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
);

View file

@ -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<string, ProvisioningRun>();
private readonly provisioningRunByTeam = new Map<string, string>();
@ -5271,6 +5274,8 @@ export class TeamProvisioningService {
Promise<OpenCodeMemberInboxRelayResult>
>();
private readonly openCodePromptDeliveryWatchdogTimers = new Map<string, NodeJS.Timeout>();
private readonly openCodeRuntimeDeliveryAdvisoryEventSentAt = new Map<string, number>();
private readonly openCodeRuntimeDeliveryLeadNoticeSentAt = new Map<string, number>();
private readonly openCodePromptDeliveryWatchdogQueue: {
teamName: string;
run: () => Promise<void>;
@ -5340,6 +5345,9 @@ export class TeamProvisioningService {
string,
Promise<RetryFailedOpenCodeSecondaryLanesResult>
>();
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<void> {
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<void> {
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<number> {

View file

@ -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;
}

View file

@ -16,10 +16,12 @@ export const WebPreviewBanner = (): React.JSX.Element | null => {
>
<FlaskConical className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div className="min-w-0">
<p className="text-sm font-medium text-amber-900">Web version is still in development</p>
<p className="text-sm font-medium text-amber-900">
Open the desktop app for full functionality
</p>
<p className="mt-1 text-xs leading-relaxed text-amber-800">
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.
</p>
</div>
</div>

View file

@ -64,9 +64,9 @@ export const TaskLogsPanel = ({
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<Tabs

View file

@ -100,6 +100,7 @@ const RELEVANT_TEAM_CHANGE_EVENT_TYPES = new Set<TeamChangeEvent['type']>([
'lead-message',
'lead-context',
'lead-activity',
'member-advisory',
'process',
'member-spawn',
]);
@ -268,6 +269,7 @@ export function initializeNotificationListeners(): () => void {
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberAdvisorySafetyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
@ -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);

View file

@ -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

View file

@ -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 '';
}

View file

@ -1201,6 +1201,7 @@ export interface TeamChangeEvent {
| 'lead-message'
| 'tool-activity'
| 'member-turn-settled'
| 'member-advisory'
| 'process'
| 'member-spawn';
teamName: string;

View file

@ -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>
): 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.');
});
});

View file

@ -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);

View file

@ -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();

View file

@ -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,

View file

@ -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',
});
});
});