feat(opencode): surface runtime delivery diagnostics
This commit is contained in:
parent
3992ab0dab
commit
f57b1bf18b
16 changed files with 1194 additions and 49 deletions
|
|
@ -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)}`)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1201,6 +1201,7 @@ export interface TeamChangeEvent {
|
|||
| 'lead-message'
|
||||
| 'tool-activity'
|
||||
| 'member-turn-settled'
|
||||
| 'member-advisory'
|
||||
| 'process'
|
||||
| 'member-spawn';
|
||||
teamName: string;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue