feat(team): harden opencode delivery advisories

This commit is contained in:
777genius 2026-05-09 13:17:23 +03:00
parent a9e7c59845
commit 8fd8949684
34 changed files with 4291 additions and 769 deletions

File diff suppressed because it is too large Load diff

View file

@ -1321,7 +1321,7 @@ function ProviderModelList({
<Input
data-testid="runtime-provider-model-search"
value={state.modelQuery}
disabled={disabled || state.modelsLoading}
disabled={disabled}
onChange={(event) => actions.setModelQuery(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}

View file

@ -964,14 +964,25 @@ function wireFileWatcherEvents(context: ServiceContext): void {
if (detail === 'config.json') {
TeamConfigReader.invalidateTeam(teamName);
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
teamDataService?.invalidateTeamRuntimeAdvisories(teamName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName);
} else if (detail === 'team.meta.json' || detail === 'members.meta.json') {
TeamConfigReader.invalidateListTeamsCache();
getTeamDataWorkerClient().invalidateTeamConfig(teamName);
teamDataService?.invalidateTeamRuntimeAdvisories(teamName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName);
}
}
if (row.type === 'task') {
TeamTaskReader.invalidateAllTasksCache();
teamDataService?.invalidateTeamRuntimeAdvisories(teamName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName);
}
if (row.type === 'member-advisory') {
teamDataService?.invalidateTeamRuntimeAdvisories(teamName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName);
}
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
@ -1279,7 +1290,8 @@ async function initializeServices(): Promise<void> {
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
teamProvisioningService = new TeamProvisioningService();
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
teamDataService?.invalidateMemberRuntimeAdvisory(teamName, memberName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(teamName, memberName);
});
publishStartupStatus({
phase: 'runtime',
@ -1413,13 +1425,23 @@ async function initializeServices(): Promise<void> {
if (event.detail === 'config.json') {
TeamConfigReader.invalidateTeam(event.teamName);
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
teamDataService?.invalidateTeamRuntimeAdvisories(event.teamName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName);
} else if (event.detail === 'team.meta.json' || event.detail === 'members.meta.json') {
TeamConfigReader.invalidateListTeamsCache();
getTeamDataWorkerClient().invalidateTeamConfig(event.teamName);
teamDataService?.invalidateTeamRuntimeAdvisories(event.teamName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName);
}
}
if (event.type === 'task') {
TeamTaskReader.invalidateAllTasksCache();
teamDataService?.invalidateTeamRuntimeAdvisories(event.teamName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName);
}
if (event.type === 'member-advisory') {
teamDataService?.invalidateTeamRuntimeAdvisories(event.teamName);
getTeamDataWorkerClient().invalidateMemberRuntimeAdvisory(event.teamName);
}
if (
teamDataService &&

View file

@ -59,6 +59,7 @@ import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
const wrapReviewHandler = createIpcWrapper('IPC:review');
const logger = createLogger('IPC:review');
const TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT = 201;
// --- Module-level state ---
@ -204,6 +205,37 @@ function sanitizeTaskChangeOptions(options?: unknown): TaskChangeRequestOptions
};
}
function sanitizeTeamTaskChangeSummaryRequests(requests: unknown): TeamTaskChangeSummaryRequest[] {
if (!Array.isArray(requests)) {
return [];
}
const sanitizedRequests: TeamTaskChangeSummaryRequest[] = [];
const seenTaskIds = new Set<string>();
for (const request of requests) {
if (sanitizedRequests.length >= TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT) {
break;
}
if (!request || typeof request !== 'object') {
continue;
}
const raw = request as Record<string, unknown>;
if (typeof raw.taskId !== 'string') {
continue;
}
const taskId = raw.taskId.trim();
if (!taskId || seenTaskIds.has(taskId)) {
continue;
}
seenTaskIds.add(taskId);
sanitizedRequests.push({
taskId,
options: sanitizeTaskChangeOptions(raw.options),
});
}
return sanitizedRequests;
}
async function handleGetTaskChanges(
_event: IpcMainInvokeEvent,
teamName: string,
@ -222,23 +254,7 @@ async function handleGetTeamTaskChangeSummaries(
teamName: string,
requests: unknown
): Promise<IpcResult<TeamTaskChangeSummariesResponse>> {
const sanitizedRequests: TeamTaskChangeSummaryRequest[] = Array.isArray(requests)
? requests
.map((request): TeamTaskChangeSummaryRequest | null => {
if (!request || typeof request !== 'object') {
return null;
}
const raw = request as Record<string, unknown>;
if (typeof raw.taskId !== 'string' || raw.taskId.trim().length === 0) {
return null;
}
return {
taskId: raw.taskId.trim(),
options: sanitizeTaskChangeOptions(raw.options),
};
})
.filter((request): request is TeamTaskChangeSummaryRequest => request !== null)
: [];
const sanitizedRequests = sanitizeTeamTaskChangeSummaryRequests(requests);
return wrapReviewHandler('getTeamTaskChangeSummaries', () =>
getChangeExtractor().getTeamTaskChangeSummaries(teamName, sanitizedRequests)

View file

@ -3056,8 +3056,10 @@ async function handleSendMessage(
ledgerStatus: delivery.ledgerStatus,
visibleReplyMessageId: delivery.visibleReplyMessageId,
visibleReplyCorrelation: delivery.visibleReplyCorrelation,
queuedBehindMessageId: delivery.queuedBehindMessageId,
reason: delivery.reason,
diagnostics: delivery.diagnostics,
userVisibleImpact: provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact(delivery),
};
if (
!delivery.delivered &&
@ -3078,6 +3080,11 @@ async function handleSendMessage(
delivered: false,
reason,
diagnostics: [reason],
userVisibleImpact: provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact({
delivered: false,
reason,
diagnostics: [reason],
}),
};
logger.warn(
`OpenCode runtime delivery after sendMessage crashed for teammate "${memberName}": ${reason}`

View file

@ -336,9 +336,21 @@ export class ChangeExtractorService {
teamName: string,
requests: TeamTaskChangeSummaryRequest[]
): Promise<TeamTaskChangeSummariesResponse> {
const cappedRequests = requests
.filter((request) => typeof request.taskId === 'string' && request.taskId.trim().length > 0)
.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT);
const inputRequests = Array.isArray(requests) ? requests : [];
const seenTaskIds = new Set<string>();
const uniqueRequests: TeamTaskChangeSummaryRequest[] = [];
for (const request of inputRequests) {
if (!request || typeof request !== 'object') {
continue;
}
const taskId = typeof request.taskId === 'string' ? request.taskId.trim() : '';
if (!taskId || seenTaskIds.has(taskId)) {
continue;
}
seenTaskIds.add(taskId);
uniqueRequests.push({ ...request, taskId });
}
const cappedRequests = uniqueRequests.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT);
const items: TeamTaskChangeSummaryItem[] = cappedRequests.map((request) => ({
taskId: request.taskId.trim(),
changeSet: null,
@ -379,7 +391,7 @@ export class ChangeExtractorService {
teamName,
items,
computedAt: new Date().toISOString(),
truncated: requests.length > cappedRequests.length || undefined,
truncated: uniqueRequests.length > cappedRequests.length || undefined,
};
}

View file

@ -497,6 +497,14 @@ export class TeamDataService {
this.memberRuntimeAdvisoryService = service;
}
invalidateMemberRuntimeAdvisory(teamName: string, memberName: string): void {
this.memberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
}
invalidateTeamRuntimeAdvisories(teamName: string): void {
this.memberRuntimeAdvisoryService.invalidateTeamAdvisories(teamName);
}
private async getMemberRuntimeAdvisoriesForSnapshot(
teamName: string,
members: readonly Pick<TeamMemberSnapshot, 'name' | 'removedAt'>[]

View file

@ -106,6 +106,11 @@ function summarizeWorkerRequest(request: TeamDataWorkerRequest): Record<string,
return {
teamName: request.payload.teamName,
};
case 'invalidateMemberRuntimeAdvisory':
return {
teamName: request.payload.teamName,
memberName: request.payload.memberName,
};
case 'findLogsForTask':
return {
teamName: request.payload.teamName,
@ -301,6 +306,16 @@ export class TeamDataWorkerClient {
this.postBestEffort('invalidateTeamMessageFeed', { teamName });
}
invalidateMemberRuntimeAdvisory(teamName: string, memberName?: string): void {
if (!SAFE_NAME_RE.test(teamName)) return;
if (memberName !== undefined && !SAFE_NAME_RE.test(memberName)) return;
this.clearTeamDataInFlightForTeam(teamName);
this.postBestEffort('invalidateMemberRuntimeAdvisory', {
teamName,
...(memberName ? { memberName } : {}),
});
}
private clearMessagesPageInFlightForTeam(teamName: string): void {
const prefix = `{"teamName":"${teamName}",`;
for (const key of this.getMessagesPageInFlight.keys()) {

View file

@ -6,21 +6,24 @@ import {
createOpenCodePromptDeliveryLedgerStore,
type OpenCodePromptDeliveryLedgerRecord,
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
import {
classifyOpenCodeRuntimeDeliveryReasonCode,
decideOpenCodeRuntimeDeliveryAdvisory,
getOpenCodeRuntimeDeliveryRecordTimeMs,
isPotentialOpenCodeRuntimeDeliveryError,
isTerminalSuccessfulOpenCodeDeliveryRecord,
} from './opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy';
import {
type OpenCodeRuntimeDeliveryProofIndex,
OpenCodeRuntimeDeliveryProofReader,
} from './opencode/delivery/OpenCodeRuntimeDeliveryProofReader';
import {
getOpenCodeLaneScopedRuntimeFilePath,
readOpenCodeRuntimeLaneIndex,
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import { TeamTaskReader } from './TeamTaskReader';
import type {
MemberLogSummary,
MemberRuntimeAdvisory,
ResolvedTeamMember,
TeamTask,
} from '@shared/types';
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
interface RuntimeAdvisoryLogFileRef {
memberName: string;
@ -47,66 +50,6 @@ 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',
'key limit exceeded',
'total limit',
];
const RATE_LIMITED_TOKENS = [
'rate limit',
'too many requests',
'429',
'model cooldown',
'cooling down',
];
const AUTH_ERROR_TOKENS = [
'auth_unavailable',
'no auth available',
'authentication_failed',
'unauthorized',
'forbidden',
'invalid api key',
'authentication',
'api key',
'does not have access',
'please run /login',
];
const CODEX_NATIVE_TIMEOUT_TOKENS = ['codex native exec timed out'];
const NETWORK_ERROR_TOKENS = [
'timeout',
'timed out',
'network',
'connection',
'econn',
'enotfound',
'fetch failed',
];
const PROVIDER_OVERLOADED_TOKENS = [
'overloaded',
'temporarily unavailable',
'service unavailable',
'503',
];
const PROTOCOL_PROOF_MISSING_TOKENS = [
'non_visible_tool_without_task_progress',
'visible_reply_still_required',
'visible_reply_ack_only_still_requires_answer',
'plain_text_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'visible_reply_missing_task_refs',
'visible_reply_missing_task_refs_after_merge',
'visible_reply_task_refs_merge_failed',
'did not create a visible reply',
'did not create a visible message_send reply',
'did not create a visible reply or task progress proof',
'without the required relayofmessageid correlation',
'without the required taskrefs metadata',
];
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
interface CachedRuntimeAdvisory {
@ -120,81 +63,6 @@ interface CachedTeamBatchAdvisories {
expiresAt: number;
}
interface OpenCodeRuntimeDeliverySupersedingProofTimes {
visibleReplyTimes: ReadonlyMap<string, number>;
taskProgressTimes: ReadonlyMap<string, number>;
}
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
return tokens.some((token) => value.includes(token));
}
function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory['reasonCode'] {
const normalized = message?.trim().toLowerCase();
if (!normalized) {
return 'unknown';
}
if (includesAnyToken(normalized, QUOTA_EXHAUSTED_TOKENS)) {
return 'quota_exhausted';
}
if (includesAnyToken(normalized, RATE_LIMITED_TOKENS)) {
return 'rate_limited';
}
if (includesAnyToken(normalized, AUTH_ERROR_TOKENS)) {
return 'auth_error';
}
if (includesAnyToken(normalized, CODEX_NATIVE_TIMEOUT_TOKENS)) {
return 'codex_native_timeout';
}
if (includesAnyToken(normalized, NETWORK_ERROR_TOKENS)) {
return 'network_error';
}
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
return 'provider_overloaded';
}
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
return 'protocol_proof_missing';
}
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,
@ -218,8 +86,6 @@ async function mapLimit<T, R>(
}
export class TeamMemberRuntimeAdvisoryService {
private readonly inboxReader = new TeamInboxReader();
private readonly taskReader = new TeamTaskReader();
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
private readonly cacheGenerationByTeam = new Map<string, number>();
@ -229,7 +95,8 @@ export class TeamMemberRuntimeAdvisoryService {
>();
constructor(
private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder()
private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder(),
private readonly proofReader = new OpenCodeRuntimeDeliveryProofReader()
) {}
invalidateMemberAdvisory(teamName: string, memberName: string): void {
@ -249,6 +116,26 @@ export class TeamMemberRuntimeAdvisoryService {
}
}
invalidateTeamAdvisories(teamName: string): void {
const teamKey = this.normalizeToken(teamName);
if (!teamKey) {
return;
}
this.cacheGenerationByTeam.set(teamKey, (this.cacheGenerationByTeam.get(teamKey) ?? 0) + 1);
this.teamBatchCacheByTeam.delete(teamKey);
for (const key of this.memberCache.keys()) {
if (key.startsWith(`${teamKey}::`)) {
this.memberCache.delete(key);
}
}
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'>[]
@ -553,9 +440,9 @@ export class TeamMemberRuntimeAdvisoryService {
for (const [memberKey, records] of recordsByMember) {
if (
records.some((record) => {
const observedAt = getRecordTimeMs(record);
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
return (
isPotentialRuntimeDeliveryError(record) &&
isPotentialOpenCodeRuntimeDeliveryError(record) &&
Number.isFinite(observedAt) &&
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
);
@ -568,11 +455,21 @@ export class TeamMemberRuntimeAdvisoryService {
return new Map();
}
const supersedingProofTimes = await this.readOpenCodeRuntimeDeliverySupersedingProofTimes(
teamName,
memberKeysWithRecentErrors,
recordsByMember
);
const proofIndex = await this.proofReader
.readProofIndex({
teamName,
activeMemberKeys: memberKeysWithRecentErrors,
recordsByMember,
})
.catch((error) => {
logger.warn('OpenCode runtime delivery proof lookup failed; using empty proof index', {
teamName,
error: error instanceof Error ? error.message : String(error),
});
return {
getSnapshot: () => ({}),
} satisfies OpenCodeRuntimeDeliveryProofIndex;
});
const result = new Map<string, MemberRuntimeAdvisory>();
for (const [memberKey, records] of recordsByMember) {
if (!memberKeysWithRecentErrors.has(memberKey)) {
@ -580,12 +477,7 @@ export class TeamMemberRuntimeAdvisoryService {
}
const originalName = activeMembersByKey.get(memberKey);
const advisory = originalName
? this.buildOpenCodeDeliveryAdvisoryFromRecords(
originalName,
records,
now,
supersedingProofTimes
)
? this.buildOpenCodeDeliveryAdvisoryFromRecords(originalName, records, now, proofIndex)
: null;
if (advisory && originalName) {
result.set(originalName, advisory);
@ -606,16 +498,20 @@ export class TeamMemberRuntimeAdvisoryService {
memberName: string,
records: readonly OpenCodePromptDeliveryLedgerRecord[],
now: number,
supersedingProofTimes: OpenCodeRuntimeDeliverySupersedingProofTimes
proofIndex: OpenCodeRuntimeDeliveryProofIndex
): MemberRuntimeAdvisory | null {
const ordered = records
.slice()
.sort((left, right) => getRecordTimeMs(right) - getRecordTimeMs(left));
const latestSuccess = ordered.find(isTerminalSuccessfulRecord);
.sort(
(left, right) =>
getOpenCodeRuntimeDeliveryRecordTimeMs(right) -
getOpenCodeRuntimeDeliveryRecordTimeMs(left)
);
const latestSuccess = ordered.find(isTerminalSuccessfulOpenCodeDeliveryRecord);
const latestError = ordered.find((record) => {
const observedAt = getRecordTimeMs(record);
const observedAt = getOpenCodeRuntimeDeliveryRecordTimeMs(record);
return (
isPotentialRuntimeDeliveryError(record) &&
isPotentialOpenCodeRuntimeDeliveryError(record) &&
Number.isFinite(observedAt) &&
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
);
@ -623,212 +519,35 @@ export class TeamMemberRuntimeAdvisoryService {
if (!latestError) {
return null;
}
if (latestSuccess && getRecordTimeMs(latestSuccess) > getRecordTimeMs(latestError)) {
return null;
}
if (
this.hasSupersedingProofForOpenCodeDeliveryRecord(
memberName,
latestError,
supersedingProofTimes
)
latestSuccess &&
getOpenCodeRuntimeDeliveryRecordTimeMs(latestSuccess) >
getOpenCodeRuntimeDeliveryRecordTimeMs(latestError)
) {
return null;
}
const message = selectOpenCodeRuntimeDeliveryReason(latestError);
if (!message) {
const decision = decideOpenCodeRuntimeDeliveryAdvisory({
record: latestError,
proof: proofIndex.getSnapshot(memberName, latestError),
now,
});
if (decision.action !== 'surface') {
return null;
}
const message = decision.reason;
if (!message || !decision.observedAt) {
return null;
}
const observedAt = getRecordTimeMs(latestError);
return {
kind: 'api_error',
observedAt: new Date(Number.isFinite(observedAt) ? observedAt : now).toISOString(),
reasonCode: classifyRetryReason(message),
observedAt: decision.observedAt,
reasonCode: decision.reasonCode,
message,
};
}
private async readOpenCodeRuntimeDeliverySupersedingProofTimes(
teamName: string,
activeMemberKeys: ReadonlySet<string>,
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
): Promise<OpenCodeRuntimeDeliverySupersedingProofTimes> {
const [visibleReplyTimes, taskProgressTimes] = await Promise.all([
this.readVisibleOpenCodeRuntimeDeliveryReplyTimes(teamName, activeMemberKeys),
this.readTaskProgressProofTimes(teamName, activeMemberKeys, recordsByMember),
]);
return { visibleReplyTimes, taskProgressTimes };
}
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 hasSupersedingProofForOpenCodeDeliveryRecord(
memberName: string,
record: OpenCodePromptDeliveryLedgerRecord,
proofTimes: OpenCodeRuntimeDeliverySupersedingProofTimes
): boolean {
if (
this.hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
memberName,
record,
proofTimes.visibleReplyTimes
)
) {
return true;
}
return this.hasTaskProgressProofForOpenCodeDeliveryRecord(
memberName,
record,
proofTimes.taskProgressTimes
);
}
private async readTaskProgressProofTimes(
teamName: string,
activeMemberKeys: ReadonlySet<string>,
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
): Promise<Map<string, number>> {
const taskIdsByMember = new Map<string, Set<string>>();
for (const [memberKey, records] of recordsByMember) {
if (!activeMemberKeys.has(memberKey)) {
continue;
}
for (const record of records) {
for (const taskRef of record.taskRefs) {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
continue;
}
const taskIds = taskIdsByMember.get(memberKey) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByMember.set(memberKey, taskIds);
}
}
}
if (taskIdsByMember.size === 0) {
return new Map();
}
const tasks = await this.taskReader.getTasks(teamName).catch(() => []);
if (tasks.length === 0) {
return new Map();
}
const result = new Map<string, number>();
for (const task of tasks) {
const taskId = task.id?.trim();
if (!taskId) {
continue;
}
for (const [memberKey, taskIds] of taskIdsByMember) {
if (!taskIds.has(taskId)) {
continue;
}
const proofAt = this.getLatestMemberTaskProgressTime(task, memberKey);
if (proofAt <= 0) {
continue;
}
const key = this.getOpenCodeTaskProgressProofKey(memberKey, taskId);
result.set(key, Math.max(result.get(key) ?? 0, proofAt));
}
}
return result;
}
private getLatestMemberTaskProgressTime(task: TeamTask, memberKey: string): number {
let latest = 0;
for (const comment of task.comments ?? []) {
if (this.normalizeToken(comment.author) !== memberKey) {
continue;
}
const createdAt = Date.parse(comment.createdAt);
if (Number.isFinite(createdAt)) {
latest = Math.max(latest, createdAt);
}
}
for (const event of task.historyEvents ?? []) {
if (this.normalizeToken(event.actor ?? '') !== memberKey) {
continue;
}
const timestamp = Date.parse(event.timestamp);
if (Number.isFinite(timestamp)) {
latest = Math.max(latest, timestamp);
}
}
return latest;
}
private hasTaskProgressProofForOpenCodeDeliveryRecord(
memberName: string,
record: OpenCodePromptDeliveryLedgerRecord,
taskProgressTimes: ReadonlyMap<string, number>
): boolean {
const recordTime = getRecordTimeMs(record);
if (!Number.isFinite(recordTime) || recordTime <= 0 || record.taskRefs.length === 0) {
return false;
}
const memberKey = this.normalizeToken(memberName);
return record.taskRefs.some((taskRef) => {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
return false;
}
const proofAt = taskProgressTimes.get(
this.getOpenCodeTaskProgressProofKey(memberKey, taskId)
);
return typeof proofAt === 'number' && proofAt > recordTime;
});
}
private getOpenCodeRuntimeReplyKey(memberKey: string, relayOfMessageId: string): string {
return `${memberKey}::${relayOfMessageId.trim()}`;
}
private getOpenCodeTaskProgressProofKey(memberKey: string, taskId: string): string {
return `${memberKey}::task::${taskId.trim()}`;
}
private async findRecentMemberAdvisoriesFromBatchRefs(
teamName: string,
memberNames: readonly string[]
@ -982,7 +701,7 @@ export class TeamMemberRuntimeAdvisoryService {
observedAt: new Date(observedAt).toISOString(),
retryUntil: new Date(retryUntil).toISOString(),
retryDelayMs: retryInMs,
reasonCode: classifyRetryReason(message),
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(message),
...(message ? { message } : {}),
};
} catch {
@ -1034,7 +753,7 @@ export class TeamMemberRuntimeAdvisoryService {
return {
kind: 'api_error',
observedAt: new Date(observedAt).toISOString(),
reasonCode: classifyRetryReason(message || parsed.error),
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(message || parsed.error),
...(message ? { message } : {}),
...(statusMatch ? { statusCode: Number(statusMatch[1]) } : {}),
};

View file

@ -199,9 +199,24 @@ import {
type OpenCodeVisibleReplyProof,
} from './opencode/delivery/OpenCodePromptDeliveryWatchdog';
import {
isActionRequiredOpenCodeRuntimeDeliveryReason,
selectOpenCodeRuntimeDeliveryReason,
} from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
classifyOpenCodeRuntimeDeliveryReasonCode,
decideOpenCodeRuntimeDeliveryAdvisory,
isDeferredGenericOpenCodeRuntimeDeliveryReason,
isPotentialOpenCodeRuntimeDeliveryError,
toOpenCodeRuntimeDeliveryUserVisibleImpact,
type OpenCodeRuntimeDeliveryAdvisoryDecision,
} from './opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy';
import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
import {
getOpenCodeVisibleReplyInboxCandidates as resolveOpenCodeVisibleReplyInboxCandidates,
isOpenCodeLeadReplyRecipientAlias as isOpenCodeLeadReplyRecipientAliasValue,
isOpenCodeRecoveredVisibleReplyCandidate as isOpenCodeRecoveredVisibleReplyCandidateValue,
isOpenCodeVisibleReplyTimestampEligible as isOpenCodeVisibleReplyTimestampEligibleValue,
normalizeOpenCodeTaskRefsForComparison as normalizeOpenCodeTaskRefsForComparisonValue,
openCodeTaskRefKey as openCodeTaskRefKeyValue,
openCodeTaskRefsIncludeAll as openCodeTaskRefsIncludeAllValue,
} from './opencode/delivery/OpenCodeRuntimeDeliveryProofMatching';
import { OpenCodeRuntimeDeliveryProofReader } from './opencode/delivery/OpenCodeRuntimeDeliveryProofReader';
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
import {
type RuntimeDeliveryDestinationPort,
@ -483,6 +498,7 @@ import type {
OpenCodeAppManagedBootstrapCandidate,
OpenCodeBootstrapEvidenceSource,
OpenCodeRuntimeDeliveryStatus,
OpenCodeRuntimeDeliveryUserVisibleImpact,
PersistedTeamLaunchMemberState,
PersistedTeamLaunchPhase,
PersistedTeamLaunchSnapshot,
@ -5333,6 +5349,7 @@ interface OpenCodeMemberInboxDelivery {
queuedBehindMessageId?: string;
reason?: string;
diagnostics?: string[];
userVisibleImpact?: OpenCodeRuntimeDeliveryUserVisibleImpact;
}
type OpenCodeVisibleReplyCorrelation = NonNullable<
@ -5478,8 +5495,10 @@ export class TeamProvisioningService {
Promise<OpenCodeMemberInboxRelayResult>
>();
private readonly openCodePromptDeliveryWatchdogTimers = new Map<string, NodeJS.Timeout>();
private readonly openCodeRuntimeDeliveryAdvisoryReviewTimers = new Map<string, NodeJS.Timeout>();
private readonly openCodeRuntimeDeliveryAdvisoryEventSentAt = new Map<string, number>();
private readonly openCodeRuntimeDeliveryLeadNoticeSentAt = new Map<string, number>();
private readonly openCodeRuntimeDeliveryProofReader = new OpenCodeRuntimeDeliveryProofReader();
private readonly openCodePromptDeliveryWatchdogQueue: {
teamName: string;
run: () => Promise<void>;
@ -7306,12 +7325,7 @@ export class TeamProvisioningService {
message: InboxMessage;
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
}): boolean {
const messageMs = Date.parse(input.message.timestamp);
const inboxMs = Date.parse(input.ledgerRecord.inboxTimestamp);
if (!Number.isFinite(messageMs) || !Number.isFinite(inboxMs)) {
return true;
}
return messageMs + 5_000 >= inboxMs;
return isOpenCodeVisibleReplyTimestampEligibleValue(input);
}
private isOpenCodeRecoveredVisibleReplyCandidate(input: {
@ -7320,33 +7334,7 @@ export class TeamProvisioningService {
from: string;
requireTaskRefs: boolean;
}): boolean {
const expectedFrom = input.from.trim().toLowerCase();
if (!expectedFrom || input.message.from.trim().toLowerCase() !== expectedFrom) {
return false;
}
if (input.message.source !== undefined && input.message.source !== 'runtime_delivery') {
return false;
}
if (
input.requireTaskRefs &&
!this.openCodeTaskRefsIncludeAll(input.message.taskRefs, input.ledgerRecord.taskRefs)
) {
return false;
}
if (
!this.isOpenCodeVisibleReplyTimestampEligible({
message: input.message,
ledgerRecord: input.ledgerRecord,
})
) {
return false;
}
return isOpenCodeVisibleReplySemanticallySufficient({
actionMode: input.ledgerRecord.actionMode,
taskRefs: input.ledgerRecord.taskRefs,
text: input.message.text,
summary: input.message.summary,
}).sufficient;
return isOpenCodeRecoveredVisibleReplyCandidateValue(input);
}
private async correlateOpenCodeRecoveredVisibleReply(input: {
@ -7536,88 +7524,37 @@ export class TeamProvisioningService {
replyRecipient?: string | null;
includeUserFallbackForLeadRecipient?: boolean;
}): Promise<string[]> {
const explicitRecipient = input.replyRecipient?.trim() || 'user';
const candidates = [explicitRecipient];
const configuredLeadName = await this.readConfigForObservation(input.teamName)
.then(
(config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null
)
.catch(() => null);
const isConfiguredLeadRecipient =
Boolean(configuredLeadName) &&
configuredLeadName?.toLowerCase() === explicitRecipient.toLowerCase();
if (this.isOpenCodeLeadReplyRecipientAlias(explicitRecipient) || isConfiguredLeadRecipient) {
if (configuredLeadName) {
candidates.push(configuredLeadName);
}
candidates.push('lead');
candidates.push('team-lead');
if (input.includeUserFallbackForLeadRecipient) {
candidates.push('user');
}
}
return candidates
.filter((value): value is string => Boolean(value && value.trim()))
.filter(
(value, index, list) =>
list.findIndex((item) => item.toLowerCase() === value.toLowerCase()) === index
);
return resolveOpenCodeVisibleReplyInboxCandidates({
replyRecipient: input.replyRecipient,
configuredLeadName,
includeUserFallbackForLeadRecipient: input.includeUserFallbackForLeadRecipient,
});
}
private isOpenCodeLeadReplyRecipientAlias(value: string): boolean {
const normalized = value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-');
return (
normalized === 'lead' ||
normalized === 'team-lead' ||
normalized === 'teamlead' ||
normalized === 'team-leader'
);
return isOpenCodeLeadReplyRecipientAliasValue(value);
}
private openCodeTaskRefsIncludeAll(
actual: readonly TaskRef[] | undefined,
expected: readonly TaskRef[] | undefined
): boolean {
const normalizedExpected = this.normalizeOpenCodeTaskRefsForComparison(expected);
if (normalizedExpected.length === 0) {
return true;
}
const actualKeys = new Set(
this.normalizeOpenCodeTaskRefsForComparison(actual).map((taskRef) =>
this.openCodeTaskRefKey(taskRef)
)
);
return normalizedExpected.every((taskRef) => actualKeys.has(this.openCodeTaskRefKey(taskRef)));
return openCodeTaskRefsIncludeAllValue(actual, expected);
}
private normalizeOpenCodeTaskRefsForComparison(
taskRefs: readonly TaskRef[] | undefined
): TaskRef[] {
if (!Array.isArray(taskRefs)) {
return [];
}
const normalized: TaskRef[] = [];
for (const rawTaskRef of taskRefs as readonly unknown[]) {
if (!rawTaskRef || typeof rawTaskRef !== 'object') {
continue;
}
const taskRef = rawTaskRef as Record<string, unknown>;
const teamName = typeof taskRef.teamName === 'string' ? taskRef.teamName.trim() : '';
const taskId = typeof taskRef.taskId === 'string' ? taskRef.taskId.trim() : '';
const displayId = typeof taskRef.displayId === 'string' ? taskRef.displayId.trim() : '';
if (teamName && taskId && displayId) {
normalized.push({ teamName, taskId, displayId });
}
}
return normalized;
return normalizeOpenCodeTaskRefsForComparisonValue(taskRefs);
}
private openCodeTaskRefKey(taskRef: TaskRef): string {
return `${taskRef.teamName.trim()}\u0000${taskRef.taskId.trim()}\u0000${taskRef.displayId.trim()}`;
return openCodeTaskRefKeyValue(taskRef);
}
private async ensureOpenCodeVisibleReplyTaskRefs(input: {
@ -8446,59 +8383,84 @@ export class TeamProvisioningService {
const shouldNotifyTerminalFailure =
event === 'opencode_prompt_delivery_terminal_failure' && record.status === 'failed_terminal';
const shouldNotifyActionRequiredRetry =
!shouldNotifyTerminalFailure &&
this.shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal(record);
!shouldNotifyTerminalFailure && isPotentialOpenCodeRuntimeDeliveryError(record);
if (shouldNotifyTerminalFailure || shouldNotifyActionRequiredRetry) {
void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => {
void this.handleOpenCodeRuntimeDeliveryUserFacingSideEffects(record).catch((error) => {
logger.warn(
`[${record.teamName}] Failed to fire OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}`
`[${record.teamName}] Failed to handle OpenCode runtime delivery advisory side effects for ${record.memberName}: ${getErrorMessage(error)}`
);
});
}
}
private async handleOpenCodeRuntimeDeliveryUserFacingSideEffects(
record: OpenCodePromptDeliveryLedgerRecord
): Promise<void> {
const { record: latestRecord, decision } =
await this.decideOpenCodeRuntimeDeliveryUserFacingAdvisory(record);
if (decision.action === 'defer') {
this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(latestRecord, decision);
this.scheduleOpenCodeRuntimeDeliveryAdvisoryReview(latestRecord, decision);
return;
}
if (this.shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(record)) {
this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(record);
if (decision.action === 'suppress') {
this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(latestRecord, decision);
return;
}
this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(latestRecord, decision);
if (decision.severity !== 'error') {
return;
}
await this.fireOpenCodeRuntimeDeliveryErrorNotification(latestRecord, decision);
}
private shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(
private async decideOpenCodeRuntimeDeliveryUserFacingAdvisory(
record: OpenCodePromptDeliveryLedgerRecord
): boolean {
if (!selectOpenCodeRuntimeDeliveryReason(record)) {
return false;
): Promise<{
record: OpenCodePromptDeliveryLedgerRecord;
decision: OpenCodeRuntimeDeliveryAdvisoryDecision;
}> {
const memberKey = record.memberName.trim().toLowerCase();
let recordsForMember: OpenCodePromptDeliveryLedgerRecord[] = [record];
try {
const laneRecords = await this.createOpenCodePromptDeliveryLedger(
record.teamName,
record.laneId
).list();
recordsForMember = laneRecords.filter(
(candidate) => candidate.memberName.trim().toLowerCase() === memberKey
);
} catch {
recordsForMember = [record];
}
if (record.status === 'failed_terminal') {
return true;
}
if (record.status === 'responded') {
return false;
}
return (
record.responseState === 'session_error' ||
record.responseState === 'tool_error' ||
record.responseState === 'permission_blocked' ||
record.responseState === 'reconcile_failed'
);
}
private shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal(
record: OpenCodePromptDeliveryLedgerRecord
): boolean {
if (!this.shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(record)) {
return false;
}
if (record.status === 'failed_terminal') {
return false;
}
return isActionRequiredOpenCodeRuntimeDeliveryReason(
selectOpenCodeRuntimeDeliveryReason(record)
);
const latestRecord = recordsForMember.find((candidate) => candidate.id === record.id) ?? record;
const recordsByMember = new Map<string, readonly OpenCodePromptDeliveryLedgerRecord[]>([
[memberKey, recordsForMember.length > 0 ? recordsForMember : [latestRecord]],
]);
const activeMemberKeys = new Set([memberKey]);
const proofIndex = await this.openCodeRuntimeDeliveryProofReader
.readProofIndex({
teamName: latestRecord.teamName,
activeMemberKeys,
recordsByMember,
})
.catch(() => null);
return {
record: latestRecord,
decision: decideOpenCodeRuntimeDeliveryAdvisory({
record: latestRecord,
proof: proofIndex?.getSnapshot(latestRecord.memberName, latestRecord),
}),
};
}
private async fireOpenCodeRuntimeDeliveryErrorNotification(
record: OpenCodePromptDeliveryLedgerRecord
record: OpenCodePromptDeliveryLedgerRecord,
decision: OpenCodeRuntimeDeliveryAdvisoryDecision
): Promise<void> {
const reason = this.selectOpenCodeRuntimeDeliveryNotificationReason(record);
const reason = decision.reason;
if (!reason) {
return;
}
@ -8536,8 +8498,6 @@ export class TeamProvisioningService {
);
}
this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(record);
await this.notifyLeadAboutOpenCodeRuntimeDeliveryError({
record,
reason,
@ -8546,7 +8506,8 @@ export class TeamProvisioningService {
}
private emitOpenCodeRuntimeDeliveryAdvisoryEvent(
record: OpenCodePromptDeliveryLedgerRecord
record: OpenCodePromptDeliveryLedgerRecord,
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
): void {
try {
this.memberRuntimeAdvisoryInvalidator?.(record.teamName, record.memberName);
@ -8556,7 +8517,7 @@ export class TeamProvisioningService {
);
}
const reasonKey = this.getOpenCodeRuntimeDeliveryAdvisoryReasonKey(record);
const reasonKey = this.getOpenCodeRuntimeDeliveryAdvisoryReasonKey(record, decision);
const eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}:${reasonKey}`;
const now = Date.now();
this.pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now);
@ -8623,10 +8584,14 @@ export class TeamProvisioningService {
}
private getOpenCodeRuntimeDeliveryAdvisoryReasonKey(
record: OpenCodePromptDeliveryLedgerRecord
record: OpenCodePromptDeliveryLedgerRecord,
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
): string {
const reason =
selectOpenCodeRuntimeDeliveryReason(record) ?? record.responseState ?? record.status;
decision?.reason ??
selectOpenCodeRuntimeDeliveryReason(record) ??
record.responseState ??
record.status;
const normalized = reason
.toLowerCase()
.replace(/https?:\/\/\S+/g, '')
@ -8636,6 +8601,31 @@ export class TeamProvisioningService {
return normalized || 'unknown';
}
private scheduleOpenCodeRuntimeDeliveryAdvisoryReview(
record: OpenCodePromptDeliveryLedgerRecord,
decision: OpenCodeRuntimeDeliveryAdvisoryDecision
): void {
const reviewAt = Date.parse(decision.nextReviewAt ?? '');
if (!Number.isFinite(reviewAt)) {
return;
}
const delayMs = Math.max(250, reviewAt - Date.now());
const timerKey = `${record.teamName}:${record.laneId}:${record.id}`;
const existing = this.openCodeRuntimeDeliveryAdvisoryReviewTimers.get(timerKey);
if (existing) {
clearTimeout(existing);
}
const timer = setTimeout(() => {
this.openCodeRuntimeDeliveryAdvisoryReviewTimers.delete(timerKey);
void this.handleOpenCodeRuntimeDeliveryUserFacingSideEffects(record).catch((error) => {
logger.warn(
`[${record.teamName}] Failed to refresh deferred OpenCode runtime delivery advisory for ${record.memberName}: ${getErrorMessage(error)}`
);
});
}, delayMs);
this.openCodeRuntimeDeliveryAdvisoryReviewTimers.set(timerKey, timer);
}
private async notifyLeadAboutOpenCodeRuntimeDeliveryError(input: {
record: OpenCodePromptDeliveryLedgerRecord;
reason: string;
@ -8682,12 +8672,6 @@ export class TeamProvisioningService {
}
}
private selectOpenCodeRuntimeDeliveryNotificationReason(
record: OpenCodePromptDeliveryLedgerRecord
): string | null {
return selectOpenCodeRuntimeDeliveryReason(record);
}
async scanOpenCodePromptDeliveryWatchdog(teamName: string): Promise<number> {
if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) {
return 0;
@ -12256,7 +12240,9 @@ export class TeamProvisioningService {
.catch(() => []);
const record = records.find((candidate) => candidate.inboxMessageId === normalizedMessageId);
if (record) {
return this.toOpenCodeRuntimeDeliveryStatus(record);
const { record: latestRecord, decision } =
await this.decideOpenCodeRuntimeDeliveryUserFacingAdvisory(record);
return this.toOpenCodeRuntimeDeliveryStatus(latestRecord, decision);
}
}
return null;
@ -12395,13 +12381,75 @@ export class TeamProvisioningService {
});
}
buildOpenCodeRuntimeDeliveryUserVisibleImpact(input: {
delivered?: boolean;
responsePending?: boolean;
acceptanceUnknown?: boolean;
responseState?: OpenCodeMemberInboxDelivery['responseState'];
ledgerStatus?: OpenCodePromptDeliveryStatus;
reason?: string;
diagnostics?: string[];
queuedBehindMessageId?: string;
policyImpact?: OpenCodeRuntimeDeliveryUserVisibleImpact;
}): OpenCodeRuntimeDeliveryUserVisibleImpact {
if (input.policyImpact) {
return input.policyImpact;
}
if (
input.responsePending === true ||
input.acceptanceUnknown === true ||
Boolean(input.queuedBehindMessageId)
) {
return {
state: 'checking',
reasonCode: input.reason
? classifyOpenCodeRuntimeDeliveryReasonCode(input.reason)
: undefined,
message: input.reason,
};
}
if (input.delivered === false) {
const reason = input.reason ?? input.diagnostics?.find((diagnostic) => diagnostic.trim());
if (
input.ledgerStatus === 'failed_terminal' &&
isDeferredGenericOpenCodeRuntimeDeliveryReason(reason)
) {
return {
state: 'checking',
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
message: reason,
};
}
return {
state: 'error',
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
message: reason,
};
}
return input.policyImpact ?? { state: 'none' };
}
private toOpenCodeRuntimeDeliveryStatus(
record: OpenCodePromptDeliveryLedgerRecord
record: OpenCodePromptDeliveryLedgerRecord,
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
): OpenCodeRuntimeDeliveryStatus {
const failed = record.status === 'failed_terminal';
const responded =
record.status === 'responded' &&
Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId);
const policyImpact = decision
? toOpenCodeRuntimeDeliveryUserVisibleImpact(decision)
: undefined;
const userVisibleImpact = this.buildOpenCodeRuntimeDeliveryUserVisibleImpact({
delivered: !failed,
responsePending: !failed && !responded,
acceptanceUnknown: record.acceptanceUnknown,
responseState: record.responseState,
ledgerStatus: record.status,
reason: record.lastReason ?? undefined,
diagnostics: record.diagnostics,
policyImpact,
});
return {
messageId: record.inboxMessageId,
providerId: 'opencode',
@ -12415,6 +12463,7 @@ export class TeamProvisioningService {
acceptanceUnknown: record.acceptanceUnknown,
reason: record.lastReason ?? undefined,
diagnostics: record.diagnostics,
userVisibleImpact,
};
}

View file

@ -0,0 +1,364 @@
import {
isActionRequiredOpenCodeRuntimeDeliveryReason,
normalizeOpenCodeRuntimeDeliveryDiagnostic,
selectOpenCodeRuntimeDeliveryReason,
} from './OpenCodeRuntimeDeliveryDiagnostics';
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
import type {
MemberRuntimeAdvisory,
OpenCodeRuntimeDeliveryUserVisibleImpact,
} from '@shared/types';
export const OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS = 120_000;
export interface OpenCodeRuntimeDeliveryProofSnapshot {
latestSuccessAt?: number;
visibleReplyAt?: number;
visibleReplyMessageId?: string;
visibleReplyInbox?: string;
taskProgressAt?: number;
}
export type OpenCodeRuntimeDeliveryAdvisoryAction = 'suppress' | 'defer' | 'surface';
export type OpenCodeRuntimeDeliveryAdvisorySeverity = 'warning' | 'error';
export interface OpenCodeRuntimeDeliveryAdvisoryDecision {
action: OpenCodeRuntimeDeliveryAdvisoryAction;
reason?: string;
reasonCode?: MemberRuntimeAdvisory['reasonCode'];
severity?: OpenCodeRuntimeDeliveryAdvisorySeverity;
observedAt?: string;
nextReviewAt?: string;
}
const QUOTA_EXHAUSTED_TOKENS = [
'exhausted your capacity',
'capacity exceeded',
'quota exceeded',
'quota exhausted',
'insufficient credits',
'key limit exceeded',
'total limit',
] as const;
const RATE_LIMITED_TOKENS = [
'rate limit',
'too many requests',
'429',
'model cooldown',
'cooling down',
] as const;
const AUTH_ERROR_TOKENS = [
'auth_unavailable',
'no auth available',
'authentication_failed',
'unauthorized',
'forbidden',
'invalid api key',
'authentication',
'api key',
'does not have access',
'please run /login',
] as const;
const CODEX_NATIVE_TIMEOUT_TOKENS = ['codex native exec timed out'] as const;
const NETWORK_ERROR_TOKENS = [
'timeout',
'timed out',
'network',
'connection',
'econn',
'enotfound',
'fetch failed',
] as const;
const PROVIDER_OVERLOADED_TOKENS = [
'overloaded',
'temporarily unavailable',
'service unavailable',
'503',
] as const;
const PROTOCOL_PROOF_MISSING_TOKENS = [
'non_visible_tool_without_task_progress',
'visible_reply_still_required',
'visible_reply_ack_only_still_requires_answer',
'plain_text_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'visible_reply_missing_task_refs',
'visible_reply_missing_task_refs_after_merge',
'visible_reply_task_refs_merge_failed',
'did not create a visible reply',
'did not create a visible message_send reply',
'did not create a visible reply or task progress proof',
'without the required relayofmessageid correlation',
'without the required taskrefs metadata',
'could not be verified',
'no visible reply has been found yet',
] as const;
const DEFERRED_GENERIC_DELIVERY_TOKENS = [
...PROTOCOL_PROOF_MISSING_TOKENS,
'empty_assistant_turn',
'empty assistant turn',
'prompt_delivered_no_assistant_message',
'accepted the prompt, but no assistant turn was recorded',
'opencode runtime delivery did not complete',
'opencode message delivery observe bridge failed',
'opencode bridge command timed out',
'opencode app mcp was reattached before message delivery',
'reattached stale opencode app mcp server',
'recreated opencode session before message delivery',
'opencode session reconcile skipped because the stored session is stale',
] as const;
const HARD_RUNTIME_RESPONSE_STATES = new Set([
'session_error',
'tool_error',
'permission_blocked',
'reconcile_failed',
]);
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
return tokens.some((token) => value.includes(token));
}
function normalizeForClassification(message: string | null | undefined): string {
return normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase() ?? '';
}
export function classifyOpenCodeRuntimeDeliveryReasonCode(
message: string | undefined
): MemberRuntimeAdvisory['reasonCode'] {
const normalized = normalizeForClassification(message);
if (!normalized) {
return 'unknown';
}
if (includesAnyToken(normalized, QUOTA_EXHAUSTED_TOKENS)) {
return 'quota_exhausted';
}
if (includesAnyToken(normalized, RATE_LIMITED_TOKENS)) {
return 'rate_limited';
}
if (includesAnyToken(normalized, AUTH_ERROR_TOKENS)) {
return 'auth_error';
}
if (includesAnyToken(normalized, CODEX_NATIVE_TIMEOUT_TOKENS)) {
return 'codex_native_timeout';
}
if (includesAnyToken(normalized, NETWORK_ERROR_TOKENS)) {
return 'network_error';
}
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
return 'provider_overloaded';
}
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
return 'protocol_proof_missing';
}
return 'backend_error';
}
export function getOpenCodeRuntimeDeliveryRecordTimeMs(
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;
}
export function getOpenCodeRuntimeDeliveryPromptTimeMs(
record: OpenCodePromptDeliveryLedgerRecord
): number {
const candidates = [record.inboxTimestamp, record.acceptedAt, record.createdAt, record.updatedAt];
for (const candidate of candidates) {
const time = Date.parse(candidate ?? '');
if (Number.isFinite(time)) {
return time;
}
}
return getOpenCodeRuntimeDeliveryRecordTimeMs(record);
}
export function isTerminalSuccessfulOpenCodeDeliveryRecord(
record: OpenCodePromptDeliveryLedgerRecord
): boolean {
return (
record.status === 'responded' &&
Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId)
);
}
export function isPotentialOpenCodeRuntimeDeliveryError(
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')
);
}
export function isProofOnlyOpenCodeRuntimeDeliveryReason(
reason: string | null | undefined
): boolean {
return (
classifyOpenCodeRuntimeDeliveryReasonCode(reason ?? undefined) === 'protocol_proof_missing'
);
}
export function isDeferredGenericOpenCodeRuntimeDeliveryReason(
reason: string | null | undefined
): boolean {
const normalized = normalizeForClassification(reason);
return Boolean(normalized) && includesAnyToken(normalized, DEFERRED_GENERIC_DELIVERY_TOKENS);
}
export function isHardOpenCodeRuntimeDeliveryReason(input: {
record: OpenCodePromptDeliveryLedgerRecord;
reason: string | null | undefined;
}): boolean {
if (isActionRequiredOpenCodeRuntimeDeliveryReason(input.reason)) {
return true;
}
if (input.record.status !== 'failed_terminal') {
return input.record.responseState === 'permission_blocked';
}
if (isDeferredGenericOpenCodeRuntimeDeliveryReason(input.reason)) {
return false;
}
if (input.record.responseState && HARD_RUNTIME_RESPONSE_STATES.has(input.record.responseState)) {
return true;
}
return (
classifyOpenCodeRuntimeDeliveryReasonCode(input.reason ?? undefined) !==
'protocol_proof_missing'
);
}
export function hasSupersedingOpenCodeRuntimeDeliveryProof(input: {
record: OpenCodePromptDeliveryLedgerRecord;
proof?: OpenCodeRuntimeDeliveryProofSnapshot | null;
}): boolean {
const proof = input.proof;
if (!proof) {
return false;
}
const recordTime = getOpenCodeRuntimeDeliveryRecordTimeMs(input.record);
if (typeof proof.latestSuccessAt === 'number' && proof.latestSuccessAt > recordTime) {
return true;
}
if (typeof proof.visibleReplyAt === 'number' && proof.visibleReplyAt > 0) {
return true;
}
if (typeof proof.taskProgressAt === 'number' && proof.taskProgressAt > 0) {
return true;
}
return false;
}
export function decideOpenCodeRuntimeDeliveryAdvisory(input: {
record: OpenCodePromptDeliveryLedgerRecord;
proof?: OpenCodeRuntimeDeliveryProofSnapshot | null;
now?: number;
graceMs?: number;
}): OpenCodeRuntimeDeliveryAdvisoryDecision {
const reason = selectOpenCodeRuntimeDeliveryReason(input.record);
if (!reason) {
return { action: 'suppress' };
}
if (hasSupersedingOpenCodeRuntimeDeliveryProof(input)) {
return { action: 'suppress' };
}
const now = input.now ?? Date.now();
const graceMs = input.graceMs ?? OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS;
const recordTime = getOpenCodeRuntimeDeliveryRecordTimeMs(input.record);
const observedAt = new Date(
Number.isFinite(recordTime) && recordTime > 0 ? recordTime : now
).toISOString();
const reasonCode = classifyOpenCodeRuntimeDeliveryReasonCode(reason);
if (isHardOpenCodeRuntimeDeliveryReason({ record: input.record, reason })) {
return {
action: 'surface',
severity: 'error',
reason,
reasonCode,
observedAt,
};
}
if (input.record.status !== 'failed_terminal') {
return { action: 'suppress' };
}
if (
reasonCode === 'protocol_proof_missing' ||
isDeferredGenericOpenCodeRuntimeDeliveryReason(reason)
) {
const terminalAt = getOpenCodeRuntimeDeliveryRecordTimeMs(input.record);
const nextReviewAtMs =
Number.isFinite(terminalAt) && terminalAt > 0 ? terminalAt + graceMs : now + graceMs;
if (now < nextReviewAtMs) {
return {
action: 'defer',
reason,
reasonCode,
observedAt,
nextReviewAt: new Date(nextReviewAtMs).toISOString(),
};
}
return {
action: 'surface',
severity: reasonCode === 'protocol_proof_missing' ? 'warning' : 'error',
reason,
reasonCode,
observedAt,
};
}
return {
action: 'surface',
severity: 'error',
reason,
reasonCode,
observedAt,
};
}
export function toOpenCodeRuntimeDeliveryUserVisibleImpact(
decision: OpenCodeRuntimeDeliveryAdvisoryDecision
): OpenCodeRuntimeDeliveryUserVisibleImpact {
if (decision.action === 'suppress') {
return { state: 'none' };
}
if (decision.action === 'defer') {
return {
state: 'checking',
reasonCode: decision.reasonCode,
message: decision.reason,
observedAt: decision.observedAt,
nextReviewAt: decision.nextReviewAt,
};
}
return {
state: decision.severity === 'warning' ? 'warning' : 'error',
reasonCode: decision.reasonCode,
message: decision.reason,
observedAt: decision.observedAt,
nextReviewAt: decision.nextReviewAt,
};
}

View file

@ -105,12 +105,13 @@ function getOpenCodeRuntimeDeliveryStateFallback(
): string | null {
const state = record.responseState?.trim();
const reason = record.lastReason?.trim();
const normalizedReason = reason?.toLowerCase();
const diagnostics = record.diagnostics.map((diagnostic) => diagnostic.trim().toLowerCase());
if (state === 'empty_assistant_turn' || reason === 'empty_assistant_turn') {
if (state === 'empty_assistant_turn' || normalizedReason === 'empty_assistant_turn') {
return 'OpenCode returned an empty assistant turn.';
}
if (
reason === 'visible_reply_missing_task_refs' ||
normalizedReason === 'visible_reply_missing_task_refs' ||
diagnostics.includes('visible_reply_missing_task_refs') ||
diagnostics.includes('visible_reply_missing_task_refs_after_merge')
) {
@ -120,25 +121,25 @@ function getOpenCodeRuntimeDeliveryStateFallback(
return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.';
}
if (
reason === 'visible_reply_still_required' ||
reason === 'visible_reply_ack_only_still_requires_answer' ||
reason === 'plain_text_ack_only_still_requires_answer'
normalizedReason === 'visible_reply_still_required' ||
normalizedReason === 'visible_reply_ack_only_still_requires_answer' ||
normalizedReason === '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'
normalizedReason === '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'
normalizedReason === 'visible_reply_destination_not_found_yet' ||
normalizedReason === 'visible_reply_missing_relayofmessageid'
) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
}
if (reason === 'non_visible_tool_without_task_progress') {
if (normalizedReason === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
return null;

View file

@ -0,0 +1,163 @@
import {
isOpenCodeVisibleReplySemanticallySufficient,
type OpenCodeVisibleReplyProof,
} from './OpenCodePromptDeliveryWatchdog';
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
import type { InboxMessage, TaskRef } from '@shared/types';
export function normalizeOpenCodeRuntimeDeliveryToken(value: string | undefined): string {
return value?.trim().toLowerCase() ?? '';
}
export function isOpenCodeLeadReplyRecipientAlias(value: string): boolean {
const normalized = value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-');
return (
normalized === 'lead' ||
normalized === 'team-lead' ||
normalized === 'teamlead' ||
normalized === 'team-leader'
);
}
export function getOpenCodeVisibleReplyInboxCandidates(input: {
replyRecipient?: string | null;
configuredLeadName?: string | null;
includeUserFallbackForLeadRecipient?: boolean;
}): string[] {
const explicitRecipient = input.replyRecipient?.trim() || 'user';
const candidates = [explicitRecipient];
const configuredLeadName = input.configuredLeadName?.trim() || null;
const isConfiguredLeadRecipient =
Boolean(configuredLeadName) &&
configuredLeadName?.toLowerCase() === explicitRecipient.toLowerCase();
if (isOpenCodeLeadReplyRecipientAlias(explicitRecipient) || isConfiguredLeadRecipient) {
if (configuredLeadName) {
candidates.push(configuredLeadName);
}
candidates.push('lead');
candidates.push('team-lead');
if (input.includeUserFallbackForLeadRecipient) {
candidates.push('user');
}
}
return candidates
.filter((value): value is string => Boolean(value?.trim()))
.filter(
(value, index, list) =>
list.findIndex((item) => item.toLowerCase() === value.toLowerCase()) === index
);
}
export function isOpenCodeVisibleReplyTimestampEligible(input: {
message: Pick<InboxMessage, 'timestamp'>;
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
}): boolean {
const messageMs = Date.parse(input.message.timestamp);
const inboxMs = Date.parse(input.ledgerRecord.inboxTimestamp);
if (!Number.isFinite(messageMs) || !Number.isFinite(inboxMs)) {
return true;
}
return messageMs + 5_000 >= inboxMs;
}
export function normalizeOpenCodeTaskRefsForComparison(
taskRefs: readonly TaskRef[] | undefined
): TaskRef[] {
if (!Array.isArray(taskRefs)) {
return [];
}
const normalized: TaskRef[] = [];
for (const rawTaskRef of taskRefs as readonly unknown[]) {
if (!rawTaskRef || typeof rawTaskRef !== 'object') {
continue;
}
const taskRef = rawTaskRef as Record<string, unknown>;
const teamName = typeof taskRef.teamName === 'string' ? taskRef.teamName.trim() : '';
const taskId = typeof taskRef.taskId === 'string' ? taskRef.taskId.trim() : '';
const displayId = typeof taskRef.displayId === 'string' ? taskRef.displayId.trim() : '';
if (teamName && taskId && displayId) {
normalized.push({ teamName, taskId, displayId });
}
}
return normalized;
}
export function openCodeTaskRefKey(taskRef: TaskRef): string {
return `${taskRef.teamName.trim()}\u0000${taskRef.taskId.trim()}\u0000${taskRef.displayId.trim()}`;
}
export function openCodeTaskRefsIncludeAll(
actual: readonly TaskRef[] | undefined,
expected: readonly TaskRef[] | undefined
): boolean {
const normalizedExpected = normalizeOpenCodeTaskRefsForComparison(expected);
if (normalizedExpected.length === 0) {
return true;
}
const actualKeys = new Set(
normalizeOpenCodeTaskRefsForComparison(actual).map((taskRef) => openCodeTaskRefKey(taskRef))
);
return normalizedExpected.every((taskRef) => actualKeys.has(openCodeTaskRefKey(taskRef)));
}
export function isOpenCodeRecoveredVisibleReplyCandidate(input: {
message: InboxMessage & { messageId: string };
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
from: string;
requireTaskRefs: boolean;
}): boolean {
const expectedFrom = normalizeOpenCodeRuntimeDeliveryToken(input.from);
if (!expectedFrom || normalizeOpenCodeRuntimeDeliveryToken(input.message.from) !== expectedFrom) {
return false;
}
if (input.message.source !== undefined && input.message.source !== 'runtime_delivery') {
return false;
}
if (
input.requireTaskRefs &&
!openCodeTaskRefsIncludeAll(input.message.taskRefs, input.ledgerRecord.taskRefs)
) {
return false;
}
if (
!isOpenCodeVisibleReplyTimestampEligible({
message: input.message,
ledgerRecord: input.ledgerRecord,
})
) {
return false;
}
return isOpenCodeVisibleReplySemanticallySufficient({
actionMode: input.ledgerRecord.actionMode,
taskRefs: input.ledgerRecord.taskRefs,
text: input.message.text,
summary: input.message.summary,
}).sufficient;
}
export function getOpenCodeRuntimeDeliveryMessageTimeMs(
message: Pick<InboxMessage, 'timestamp'>
): number {
const time = Date.parse(message.timestamp);
return Number.isFinite(time) ? time : 0;
}
export function isOpenCodeVisibleReplyProofSufficient(input: {
proof: OpenCodeVisibleReplyProof;
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
}): boolean {
return (
isOpenCodeRecoveredVisibleReplyCandidate({
message: input.proof.message,
ledgerRecord: input.ledgerRecord,
from: input.ledgerRecord.memberName,
requireTaskRefs: false,
}) && openCodeTaskRefsIncludeAll(input.proof.message.taskRefs, input.ledgerRecord.taskRefs)
);
}

View file

@ -0,0 +1,361 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import { TeamConfigReader } from '../../TeamConfigReader';
import { TeamInboxReader } from '../../TeamInboxReader';
import { TeamTaskReader } from '../../TeamTaskReader';
import {
getOpenCodeRuntimeDeliveryPromptTimeMs,
getOpenCodeRuntimeDeliveryRecordTimeMs,
isTerminalSuccessfulOpenCodeDeliveryRecord,
type OpenCodeRuntimeDeliveryProofSnapshot,
} from './OpenCodeRuntimeDeliveryAdvisoryPolicy';
import {
getOpenCodeRuntimeDeliveryMessageTimeMs,
getOpenCodeVisibleReplyInboxCandidates,
isOpenCodeRecoveredVisibleReplyCandidate,
normalizeOpenCodeRuntimeDeliveryToken,
openCodeTaskRefsIncludeAll,
} from './OpenCodeRuntimeDeliveryProofMatching';
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
import type { InboxMessage, TeamConfig, TeamTask } from '@shared/types';
const PROOF_READ_CONCURRENCY = 4;
interface IndexedVisibleReply {
inboxName: string;
message: InboxMessage & { messageId: string };
observedAt: number;
}
export interface OpenCodeRuntimeDeliveryProofIndex {
getSnapshot(
memberName: string,
record: OpenCodePromptDeliveryLedgerRecord
): OpenCodeRuntimeDeliveryProofSnapshot;
}
export interface OpenCodeRuntimeDeliveryProofReaderInput {
teamName: string;
activeMemberKeys: ReadonlySet<string>;
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>;
}
interface ConfigReaderPort {
getConfigSnapshot?(teamName: string): Promise<TeamConfig | null>;
getConfig(teamName: string): Promise<TeamConfig | null>;
}
async function mapLimit<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
let index = 0;
const workerCount = Math.max(1, Math.min(limit, items.length));
const workers = new Array(workerCount).fill(0).map(async () => {
while (true) {
const currentIndex = index;
index += 1;
if (currentIndex >= items.length) {
return;
}
results[currentIndex] = await fn(items[currentIndex]);
}
});
await Promise.all(workers);
return results;
}
function getLatestMemberTaskProgressTime(task: TeamTask, memberKey: string): number {
let latest = 0;
for (const comment of task.comments ?? []) {
if (normalizeOpenCodeRuntimeDeliveryToken(comment.author) !== memberKey) {
continue;
}
const createdAt = Date.parse(comment.createdAt);
if (Number.isFinite(createdAt)) {
latest = Math.max(latest, createdAt);
}
}
for (const event of task.historyEvents ?? []) {
if (normalizeOpenCodeRuntimeDeliveryToken(event.actor ?? '') !== memberKey) {
continue;
}
const timestamp = Date.parse(event.timestamp);
if (Number.isFinite(timestamp)) {
latest = Math.max(latest, timestamp);
}
}
return latest;
}
function getOpenCodeTaskProgressProofKey(memberKey: string, taskId: string): string {
return `${memberKey}::task::${taskId.trim()}`;
}
class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDeliveryProofIndex {
constructor(
private readonly latestSuccessTimesByMember: ReadonlyMap<string, number>,
private readonly visibleRepliesByMember: ReadonlyMap<string, readonly IndexedVisibleReply[]>,
private readonly taskProgressTimes: ReadonlyMap<string, number>
) {}
getSnapshot(
memberName: string,
record: OpenCodePromptDeliveryLedgerRecord
): OpenCodeRuntimeDeliveryProofSnapshot {
const memberKey = normalizeOpenCodeRuntimeDeliveryToken(memberName);
const visibleReplies = this.visibleRepliesByMember.get(memberKey) ?? [];
const relayOfMessageId = record.inboxMessageId.trim();
const promptTime = getOpenCodeRuntimeDeliveryPromptTimeMs(record);
let visibleReply: IndexedVisibleReply | null = null;
const expectedMessageId = record.visibleReplyMessageId?.trim();
if (expectedMessageId) {
visibleReply =
visibleReplies.find(
(candidate) =>
candidate.message.messageId === expectedMessageId &&
isOpenCodeRecoveredVisibleReplyCandidate({
message: candidate.message,
ledgerRecord: record,
from: memberName,
requireTaskRefs: false,
}) &&
openCodeTaskRefsIncludeAll(candidate.message.taskRefs, record.taskRefs)
) ?? null;
}
if (!visibleReply && relayOfMessageId) {
visibleReply =
visibleReplies.find((candidate) => {
const messageRelayOfMessageId =
typeof candidate.message.relayOfMessageId === 'string'
? candidate.message.relayOfMessageId.trim()
: '';
return (
messageRelayOfMessageId === relayOfMessageId &&
isOpenCodeRecoveredVisibleReplyCandidate({
message: candidate.message,
ledgerRecord: record,
from: memberName,
requireTaskRefs: false,
}) &&
openCodeTaskRefsIncludeAll(candidate.message.taskRefs, record.taskRefs)
);
}) ?? null;
}
if (!visibleReply && record.taskRefs.length > 0) {
visibleReply =
visibleReplies
.filter((candidate) =>
isOpenCodeRecoveredVisibleReplyCandidate({
message: candidate.message,
ledgerRecord: record,
from: memberName,
requireTaskRefs: true,
})
)
.sort((left, right) => left.observedAt - right.observedAt)[0] ?? null;
}
let taskProgressAt = 0;
for (const taskRef of record.taskRefs) {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
continue;
}
const proofAt = this.taskProgressTimes.get(
getOpenCodeTaskProgressProofKey(memberKey, taskId)
);
if (typeof proofAt === 'number' && proofAt > promptTime) {
taskProgressAt = Math.max(taskProgressAt, proofAt);
}
}
return {
latestSuccessAt: this.latestSuccessTimesByMember.get(memberKey),
visibleReplyAt: visibleReply?.observedAt,
visibleReplyMessageId: visibleReply?.message.messageId,
visibleReplyInbox: visibleReply?.inboxName,
taskProgressAt: taskProgressAt > 0 ? taskProgressAt : undefined,
};
}
}
export class OpenCodeRuntimeDeliveryProofReader {
constructor(
private readonly inboxReader = new TeamInboxReader(),
private readonly taskReader = new TeamTaskReader(),
private readonly configReader: ConfigReaderPort = new TeamConfigReader()
) {}
async readProofIndex(
input: OpenCodeRuntimeDeliveryProofReaderInput
): Promise<OpenCodeRuntimeDeliveryProofIndex> {
const [configuredLeadName, visibleRepliesByMember, taskProgressTimes] = await Promise.all([
this.readConfiguredLeadName(input.teamName),
this.readVisibleRepliesByMember(input),
this.readTaskProgressProofTimes(
input.teamName,
input.activeMemberKeys,
input.recordsByMember
),
]);
return new MaterializedOpenCodeRuntimeDeliveryProofIndex(
this.readLatestSuccessTimesByMember(input.activeMemberKeys, input.recordsByMember),
await visibleRepliesByMember(configuredLeadName),
taskProgressTimes
);
}
private async readConfiguredLeadName(teamName: string): Promise<string | null> {
const config =
(typeof this.configReader.getConfigSnapshot === 'function'
? await this.configReader.getConfigSnapshot(teamName)
: await this.configReader.getConfig(teamName)) ?? null;
return config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null;
}
private async readVisibleRepliesByMember(
input: OpenCodeRuntimeDeliveryProofReaderInput
): Promise<(configuredLeadName: string | null) => Promise<Map<string, IndexedVisibleReply[]>>> {
const candidateDescriptors = new Map<
string,
{
replyRecipient?: string | null;
includeUserFallbackForLeadRecipient?: boolean;
}
>();
for (const records of input.recordsByMember.values()) {
for (const record of records) {
const includeUserFallbackForLeadRecipient = true;
const key = `${record.replyRecipient ?? ''}\u0000${includeUserFallbackForLeadRecipient ? '1' : '0'}`;
candidateDescriptors.set(key, {
replyRecipient: record.replyRecipient,
includeUserFallbackForLeadRecipient,
});
}
}
return async (configuredLeadName: string | null) => {
const inboxNames = new Set<string>();
for (const descriptor of candidateDescriptors.values()) {
for (const inboxName of getOpenCodeVisibleReplyInboxCandidates({
...descriptor,
configuredLeadName,
})) {
inboxNames.add(inboxName);
}
}
const result = new Map<string, IndexedVisibleReply[]>();
await mapLimit(Array.from(inboxNames), PROOF_READ_CONCURRENCY, async (inboxName) => {
const messages = await this.inboxReader
.getMessagesFor(input.teamName, inboxName)
.catch(() => []);
for (const message of messages) {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (!messageId) {
continue;
}
const memberKey = normalizeOpenCodeRuntimeDeliveryToken(message.from);
if (!input.activeMemberKeys.has(memberKey)) {
continue;
}
if (message.source !== undefined && message.source !== 'runtime_delivery') {
continue;
}
const observedAt = getOpenCodeRuntimeDeliveryMessageTimeMs(message);
const existing = result.get(memberKey) ?? [];
existing.push({
inboxName,
message: { ...message, messageId },
observedAt,
});
result.set(memberKey, existing);
}
});
return result;
};
}
private readLatestSuccessTimesByMember(
activeMemberKeys: ReadonlySet<string>,
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
): Map<string, number> {
const result = new Map<string, number>();
for (const [memberKey, records] of recordsByMember) {
if (!activeMemberKeys.has(memberKey)) {
continue;
}
for (const record of records) {
if (!isTerminalSuccessfulOpenCodeDeliveryRecord(record)) {
continue;
}
result.set(
memberKey,
Math.max(result.get(memberKey) ?? 0, getOpenCodeRuntimeDeliveryRecordTimeMs(record))
);
}
}
return result;
}
private async readTaskProgressProofTimes(
teamName: string,
activeMemberKeys: ReadonlySet<string>,
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
): Promise<Map<string, number>> {
const taskIdsByMember = new Map<string, Set<string>>();
for (const [memberKey, records] of recordsByMember) {
if (!activeMemberKeys.has(memberKey)) {
continue;
}
for (const record of records) {
for (const taskRef of record.taskRefs) {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
continue;
}
const taskIds = taskIdsByMember.get(memberKey) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByMember.set(memberKey, taskIds);
}
}
}
if (taskIdsByMember.size === 0) {
return new Map();
}
const tasks = await this.taskReader.getTasks(teamName).catch(() => []);
if (tasks.length === 0) {
return new Map();
}
const result = new Map<string, number>();
for (const task of tasks) {
const taskId = task.id?.trim();
if (!taskId) {
continue;
}
for (const [memberKey, taskIds] of taskIdsByMember) {
if (!taskIds.has(taskId)) {
continue;
}
const proofAt = getLatestMemberTaskProgressTime(task, memberKey);
if (proofAt <= 0) {
continue;
}
const key = getOpenCodeTaskProgressProofKey(memberKey, taskId);
result.set(key, Math.max(result.get(key) ?? 0, proofAt));
}
}
return result;
}
}

View file

@ -48,6 +48,11 @@ export interface InvalidateTeamMessageFeedPayload {
teamName: string;
}
export interface InvalidateMemberRuntimeAdvisoryPayload {
teamName: string;
memberName?: string;
}
export interface TeamDataWorkerDiag {
op: TeamDataWorkerRequest['op'];
teamName?: string;
@ -64,7 +69,12 @@ export type TeamDataWorkerRequest =
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }
| { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload }
| { id: string; op: 'invalidateTeamMessageFeed'; payload: InvalidateTeamMessageFeedPayload };
| { id: string; op: 'invalidateTeamMessageFeed'; payload: InvalidateTeamMessageFeedPayload }
| {
id: string;
op: 'invalidateMemberRuntimeAdvisory';
payload: InvalidateMemberRuntimeAdvisoryPayload;
};
export type TeamDataWorkerResponse =
| {

View file

@ -70,6 +70,7 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
case 'invalidateTeamConfig': {
TeamConfigReader.invalidateTeam(msg.payload.teamName);
teamDataService.invalidateMessageFeed(msg.payload.teamName);
teamDataService.invalidateTeamRuntimeAdvisories(msg.payload.teamName);
respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
break;
}
@ -78,6 +79,18 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
break;
}
case 'invalidateMemberRuntimeAdvisory': {
if (msg.payload.memberName) {
teamDataService.invalidateMemberRuntimeAdvisory(
msg.payload.teamName,
msg.payload.memberName
);
} else {
teamDataService.invalidateTeamRuntimeAdvisories(msg.payload.teamName);
}
respond({ id: msg.id, ok: true, result: null, diag: buildDiag() });
break;
}
case 'findLogsForTask': {
const { teamName, taskId, options } = msg.payload;
const intervalsKey = options?.intervals

View file

@ -4,28 +4,21 @@ import { api } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
import {
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { AlertTriangle, FileDiff, GitCompareArrows, Loader2, RefreshCw } from 'lucide-react';
import { FileIcon } from './editor/FileIcon';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import {
buildTeamChangeRequestPlan,
buildTeamChangesTasksFingerprint,
getTeamChangeTaskTimeMs,
TEAM_CHANGES_MAX_RENDERED_FILE_ROWS,
} from './teamChangesRequestPlan';
import type {
FileChangeSummary,
TaskChangeSetV2,
TeamTaskChangeSummaryRequest,
TeamTaskWithKanban,
} from '@shared/types';
import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types';
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
const TEAM_CHANGES_MAX_REQUESTS = 120;
const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32;
const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300;
interface TeamChangesSectionProps {
teamName: string;
@ -33,28 +26,10 @@ interface TeamChangesSectionProps {
onViewChanges: (taskId: string, filePath?: string) => void;
}
interface TeamChangeCandidate {
task: TeamTaskWithKanban;
options: TaskChangeRequestOptions;
priority: number;
isUnknownScan: boolean;
}
interface TeamChangeRequestPlan {
requests: TeamTaskChangeSummaryRequest[];
requestOptionsByTaskId: Map<string, TaskChangeRequestOptions>;
eligibleCount: number;
requestedCount: number;
deferredCount: number;
nextUnknownScanCursor: number;
}
interface TeamChangeSummaryState {
taskId: string;
changeSet: TaskChangeSetV2 | null;
error?: string;
options: TaskChangeRequestOptions;
loadedAt: number;
}
interface TeamChangeStats {
@ -63,103 +38,10 @@ interface TeamChangeStats {
deferredCount: number;
}
function getTaskTimeMs(task: TeamTaskWithKanban): number {
const value = task.updatedAt ?? task.createdAt;
if (!value) return 0;
const ms = new Date(value).getTime();
return Number.isFinite(ms) ? ms : 0;
}
function compareCandidateRecency(a: TeamChangeCandidate, b: TeamChangeCandidate): number {
const priorityDelta = a.priority - b.priority;
if (priorityDelta !== 0) return priorityDelta;
return getTaskTimeMs(b.task) - getTaskTimeMs(a.task);
}
function rotateCandidates<T>(items: T[], cursor: number): T[] {
if (items.length === 0) return items;
const start = cursor % items.length;
if (start === 0) return items;
return [...items.slice(start), ...items.slice(0, start)];
}
function buildTeamChangeRequestPlan(
tasks: TeamTaskWithKanban[],
unknownScanCursor: number,
forceFresh: boolean
): TeamChangeRequestPlan {
const primary: TeamChangeCandidate[] = [];
const active: TeamChangeCandidate[] = [];
const unknown: TeamChangeCandidate[] = [];
const seenTaskIds = new Set<string>();
for (const task of tasks) {
if (!task.id || task.status === 'deleted' || seenTaskIds.has(task.id)) {
continue;
}
seenTaskIds.add(task.id);
const options = buildTaskChangeRequestOptions(task, { summaryOnly: true });
const presence = task.changePresence ?? 'unknown';
const canDisplay = canDisplayTaskChangesForOptions(options);
if (!canDisplay && presence !== 'has_changes' && presence !== 'needs_attention') {
continue;
}
if (presence === 'has_changes') {
primary.push({ task, options, priority: 0, isUnknownScan: false });
continue;
}
if (presence === 'needs_attention') {
primary.push({ task, options, priority: 1, isUnknownScan: false });
continue;
}
if (options.stateBucket === 'active' && options.status === 'in_progress') {
active.push({ task, options, priority: 2, isUnknownScan: false });
continue;
}
if (presence === 'unknown') {
unknown.push({ task, options, priority: 3, isUnknownScan: true });
}
}
primary.sort(compareCandidateRecency);
active.sort(compareCandidateRecency);
unknown.sort(compareCandidateRecency);
const unknownWindow = rotateCandidates(unknown, unknownScanCursor).slice(
0,
TEAM_CHANGES_UNKNOWN_SCAN_LIMIT
);
const selected = [...primary, ...active, ...unknownWindow].slice(0, TEAM_CHANGES_MAX_REQUESTS);
const requestOptionsByTaskId = new Map<string, TaskChangeRequestOptions>();
const requests = selected.map((candidate) => {
const options = {
...candidate.options,
summaryOnly: true,
forceFresh: forceFresh ? true : candidate.options.forceFresh,
};
requestOptionsByTaskId.set(candidate.task.id, options);
return {
taskId: candidate.task.id,
options,
};
});
const eligibleCount = primary.length + active.length + unknown.length;
const nextUnknownScanCursor =
unknown.length > 0
? (unknownScanCursor + Math.min(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, unknown.length)) %
unknown.length
: 0;
return {
requests,
requestOptionsByTaskId,
eligibleCount,
requestedCount: requests.length,
deferredCount: Math.max(0, eligibleCount - requests.length),
nextUnknownScanCursor,
};
interface TeamChangesLoadOptions {
forceFresh?: boolean;
showSpinner?: boolean;
preserveOnError?: boolean;
}
function getTaskChangeContributors(
@ -199,21 +81,6 @@ function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefi
return undefined;
}
function buildTasksFingerprint(tasks: TeamTaskWithKanban[]): string {
return tasks
.map((task) =>
[
task.id,
task.status,
task.owner ?? '',
task.updatedAt ?? '',
task.changePresence ?? 'unknown',
task.workIntervals?.length ?? 0,
].join(':')
)
.join('|');
}
export const TeamChangesSection = memo(function TeamChangesSection({
teamName,
tasks,
@ -233,12 +100,17 @@ export const TeamChangesSection = memo(function TeamChangesSection({
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queuedRefreshTick, setQueuedRefreshTick] = useState(0);
const hasLoadedRef = useRef(false);
const requestSeqRef = useRef(0);
const activeRequestSeqRef = useRef<number | null>(null);
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
const sectionOpenRef = useRef(sectionOpen);
const unknownScanCursorRef = useRef(0);
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
const tasksFingerprint = useMemo(() => buildTasksFingerprint(tasks), [tasks]);
const tasksFingerprint = useMemo(() => buildTeamChangesTasksFingerprint(tasks), [tasks]);
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
sectionOpenRef.current = sectionOpen;
const visibleSummaries = useMemo(() => {
return Object.values(summariesByTaskId)
@ -250,7 +122,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
(entry.summary.changeSet?.files.length ?? 0) > 0 ||
(entry.summary.changeSet?.warnings.length ?? 0) > 0)
)
.sort((a, b) => getTaskTimeMs(b.task) - getTaskTimeMs(a.task));
.sort((a, b) => getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task));
}, [summariesByTaskId, taskMap]);
const totalFiles = visibleSummaries.reduce(
@ -265,13 +137,24 @@ export const TeamChangesSection = memo(function TeamChangesSection({
forceFresh = false,
showSpinner = false,
preserveOnError = true,
}: {
forceFresh?: boolean;
showSpinner?: boolean;
preserveOnError?: boolean;
} = {}): Promise<void> => {
}: TeamChangesLoadOptions = {}): Promise<void> => {
if (activeRequestSeqRef.current !== null) {
const previous = queuedRefreshOptionsRef.current;
queuedRefreshOptionsRef.current = {
forceFresh: Boolean(previous?.forceFresh || forceFresh),
showSpinner: Boolean(previous?.showSpinner || showSpinner),
preserveOnError: previous
? Boolean(previous.preserveOnError && preserveOnError)
: preserveOnError,
};
requestSeqRef.current += 1;
return;
}
const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh);
unknownScanCursorRef.current = plan.nextUnknownScanCursor;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
setStats({
eligibleCount: plan.eligibleCount,
requestedCount: plan.requestedCount,
@ -281,16 +164,17 @@ export const TeamChangesSection = memo(function TeamChangesSection({
if (plan.requests.length === 0) {
setSummariesByTaskId({});
setLoading(false);
setRefreshing(false);
return;
}
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
if (showSpinner) {
setLoading(true);
} else {
setRefreshing(true);
}
activeRequestSeqRef.current = requestSeq;
try {
const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests);
@ -312,7 +196,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
setSummariesByTaskId((previous) => {
const next: Record<string, TeamChangeSummaryState> = {};
for (const [taskId, summary] of Object.entries(previous)) {
if (currentTaskIds.has(taskId)) {
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
next[taskId] = summary;
}
}
@ -323,8 +207,6 @@ export const TeamChangesSection = memo(function TeamChangesSection({
taskId: item.taskId,
changeSet: item.changeSet,
error: item.error,
options,
loadedAt: Date.now(),
};
}
return next;
@ -338,7 +220,17 @@ export const TeamChangesSection = memo(function TeamChangesSection({
}
setError(err instanceof Error ? err.message : 'Failed to load team changes');
} finally {
if (requestSeqRef.current === requestSeq) {
const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null;
if (activeRequestSeqRef.current === requestSeq) {
activeRequestSeqRef.current = null;
}
if (hasQueuedRefresh && activeRequestSeqRef.current === null && sectionOpenRef.current) {
setQueuedRefreshTick((value) => value + 1);
}
const shouldStopIndicators =
requestSeqRef.current === requestSeq ||
(!hasQueuedRefresh && activeRequestSeqRef.current === null);
if (shouldStopIndicators) {
setLoading(false);
setRefreshing(false);
}
@ -350,6 +242,8 @@ export const TeamChangesSection = memo(function TeamChangesSection({
useEffect(() => {
hasLoadedRef.current = false;
requestSeqRef.current += 1;
activeRequestSeqRef.current = null;
queuedRefreshOptionsRef.current = null;
unknownScanCursorRef.current = 0;
lastRequestedTasksFingerprintRef.current = null;
setSummariesByTaskId({});
@ -357,6 +251,18 @@ export const TeamChangesSection = memo(function TeamChangesSection({
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
}, [teamName]);
useEffect(() => {
if (!sectionOpen) {
requestSeqRef.current += 1;
activeRequestSeqRef.current = null;
queuedRefreshOptionsRef.current = null;
hasLoadedRef.current = false;
lastRequestedTasksFingerprintRef.current = null;
setLoading(false);
setRefreshing(false);
}
}, [sectionOpen]);
useEffect(() => {
if (!sectionOpen || hasLoadedRef.current) {
return;
@ -377,12 +283,27 @@ export const TeamChangesSection = memo(function TeamChangesSection({
void loadSummaries({ showSpinner: false, preserveOnError: true });
}, [loadSummaries, sectionOpen, tasksFingerprint]);
useEffect(() => {
if (!sectionOpen || activeRequestSeqRef.current !== null) {
return;
}
const options = queuedRefreshOptionsRef.current;
if (!options) {
return;
}
queuedRefreshOptionsRef.current = null;
void loadSummaries(options);
}, [loadSummaries, queuedRefreshTick, sectionOpen]);
useEffect(() => {
if (!sectionOpen) {
return;
}
const timer = window.setInterval(() => {
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
return;
}
void loadSummaries({ showSpinner: false, preserveOnError: true });
}, TEAM_CHANGES_AUTO_REFRESH_MS);

View file

@ -53,6 +53,7 @@ import {
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
import { shouldClearPendingReplyForOpenCodeRuntimeDelivery } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
@ -3024,8 +3025,7 @@ export const TeamDetailView = memo(function TeamDetailView({
taskRefs,
});
if (
result?.runtimeDelivery?.attempted === true &&
result.runtimeDelivery.delivered === false
shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)
) {
setPendingRepliesByMember((prev) => {
if (prev[member] !== sentAtMs) return prev;

View file

@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import {
buildTeamChangeRequestPlan,
buildTeamChangesTasksFingerprint,
TEAM_CHANGES_UNKNOWN_SCAN_LIMIT,
} from '../teamChangesRequestPlan';
import type { TeamTaskWithKanban } from '@shared/types';
function task(overrides: Partial<TeamTaskWithKanban> & { id: string }): TeamTaskWithKanban {
const { id, subject, status, ...rest } = overrides;
return {
id,
subject: subject ?? `Task ${id}`,
status: status ?? 'pending',
...rest,
};
}
describe('buildTeamChangeRequestPlan', () => {
it('scans unknown pending tasks only when they have work evidence', () => {
const plan = buildTeamChangeRequestPlan(
[
task({ id: 'plain-pending', status: 'pending', changePresence: 'unknown' }),
task({
id: 'worked-pending',
status: 'pending',
changePresence: 'unknown',
workIntervals: [{ startedAt: '2026-05-09T08:00:00.000Z' }],
}),
],
0,
false
);
expect(plan.requests.map((request) => request.taskId)).toEqual(['worked-pending']);
expect([...plan.eligibleTaskIds]).toEqual(['worked-pending']);
});
it('keeps known changed tasks even when they are currently pending', () => {
const plan = buildTeamChangeRequestPlan(
[task({ id: 'known-changed', status: 'pending', changePresence: 'has_changes' })],
0,
false
);
expect(plan.requests.map((request) => request.taskId)).toEqual(['known-changed']);
expect(plan.eligibleTaskIds.has('known-changed')).toBe(true);
});
it('rotates unknown scans and preserves summary-only request options', () => {
const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) =>
task({
id: `task-${index}`,
status: 'completed',
changePresence: 'unknown',
updatedAt: `2026-05-09T08:${String(index).padStart(2, '0')}:00.000Z`,
})
);
const firstPass = buildTeamChangeRequestPlan(tasks, 0, true);
const secondPass = buildTeamChangeRequestPlan(tasks, firstPass.nextUnknownScanCursor, false);
expect(firstPass.requests).toHaveLength(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT);
expect(firstPass.requests[0].options?.summaryOnly).toBe(true);
expect(firstPass.requests[0].options?.forceFresh).toBe(true);
expect(secondPass.requests[0].taskId).toBe('task-3');
});
it('changes fingerprint when review state changes without timestamp changes', () => {
const baseTask = task({
id: 'reviewing',
status: 'completed',
changePresence: 'unknown',
updatedAt: '2026-05-09T08:00:00.000Z',
reviewState: 'none',
});
expect(buildTeamChangesTasksFingerprint([baseTask])).not.toBe(
buildTeamChangesTasksFingerprint([{ ...baseTask, reviewState: 'review' }])
);
});
it('keeps fingerprint stable for task reorder and irrelevant history events', () => {
const first = task({
id: 'task-a',
status: 'pending',
historyEvents: [
{
id: 'event-created',
type: 'task_created',
timestamp: '2026-05-09T08:00:00.000Z',
status: 'pending',
},
{
id: 'event-owner',
type: 'owner_changed',
timestamp: '2026-05-09T08:01:00.000Z',
from: 'alice',
to: 'bob',
},
],
});
const second = task({
id: 'task-b',
status: 'completed',
historyEvents: [
{
id: 'event-status',
type: 'status_changed',
timestamp: '2026-05-09T08:02:00.000Z',
from: 'in_progress',
to: 'completed',
},
],
});
expect(buildTeamChangesTasksFingerprint([first, second])).toBe(
buildTeamChangesTasksFingerprint([
second,
{
...first,
historyEvents: [
...(first.historyEvents ?? []),
{
id: 'event-owner-2',
type: 'owner_changed',
timestamp: '2026-05-09T08:03:00.000Z',
from: 'bob',
to: 'carol',
},
],
},
])
);
});
});

View file

@ -27,6 +27,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isOpenCodeRuntimeDeliveryHardUxFailure } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import {
extractTaskRefsFromText,
stripEncodedTaskReferenceMetadata,
@ -292,10 +293,7 @@ export const SendMessageDialog = ({
)
)
.then((result) => {
if (
result?.runtimeDelivery?.attempted === true &&
result.runtimeDelivery.delivered === false
) {
if (isOpenCodeRuntimeDeliveryHardUxFailure(result?.runtimeDelivery)) {
return;
}
textDraft.clearDraft();

View file

@ -19,6 +19,7 @@ import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import { nameColorSet } from '@renderer/utils/projectColor';
import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
@ -469,7 +470,9 @@ export const MessageComposer = ({
if (!hasCompletionSignal) return;
pendingSendRef.current = null;
const failed = sendError !== null || sendDebugDetails?.delivered === false;
const failed =
sendError !== null ||
isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails(sendDebugDetails);
if (failed) {
if (!isPendingCurrentTeam) return;
const currentDraftIsEmpty =

View file

@ -19,6 +19,7 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store';
import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
import { shouldClearPendingReplyForOpenCodeRuntimeDelivery } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
@ -691,12 +692,19 @@ export const MessagesPanel = memo(function MessagesPanel({
useEffect(() => {
const debugDetails = sendMessageDebugDetails;
const messageId = debugDetails?.messageId;
if (!messageId || sendMessageRuntimeReplyVisible || debugDetails?.responsePending !== true) {
const shouldPoll =
debugDetails?.userVisibleState === 'checking' ||
(!debugDetails?.userVisibleState && debugDetails?.responsePending === true);
if (!messageId || sendMessageRuntimeReplyVisible || !shouldPoll) {
return;
}
const statusMessageId = debugDetails.statusMessageId || messageId;
const timers = OPENCODE_RUNTIME_DELIVERY_STATUS_REFRESH_DELAYS_MS.map((delayMs) =>
window.setTimeout(() => {
void refreshSendMessageRuntimeDeliveryStatus(teamName, messageId);
void refreshSendMessageRuntimeDeliveryStatus(teamName, {
messageId,
statusMessageId,
});
}, delayMs)
);
return () => {
@ -705,7 +713,9 @@ export const MessagesPanel = memo(function MessagesPanel({
}, [
refreshSendMessageRuntimeDeliveryStatus,
sendMessageDebugDetails?.messageId,
sendMessageDebugDetails?.statusMessageId,
sendMessageDebugDetails?.responsePending,
sendMessageDebugDetails?.userVisibleState,
sendMessageRuntimeReplyVisible,
teamName,
]);
@ -732,10 +742,7 @@ export const MessagesPanel = memo(function MessagesPanel({
taskRefs,
})
.then((result) => {
if (
result?.runtimeDelivery?.attempted === true &&
result.runtimeDelivery.delivered === false
) {
if (shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)) {
onPendingReplyChange((prev) => {
if (prev[member] !== sentAtMs) return prev;
const next = { ...prev };

View file

@ -19,9 +19,12 @@ export const OpenCodeDeliveryWarning = ({
debugDetails,
pendingDelayMs = 10_000,
}: OpenCodeDeliveryWarningProps): JSX.Element | null => {
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}`;
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}:${debugDetails?.statusMessageId ?? ''}:${debugDetails?.userVisibleState ?? ''}`;
const delayPendingWarning =
debugDetails?.responsePending === true && debugDetails.delivered !== false;
debugDetails?.userVisibleState === 'checking' ||
(!debugDetails?.userVisibleState &&
debugDetails?.responsePending === true &&
debugDetails.delivered !== false);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [pendingVisibleKey, setPendingVisibleKey] = useState<string | null>(() =>
@ -118,6 +121,8 @@ export const OpenCodeDeliveryWarning = ({
<span className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<span className="text-[var(--color-text-muted)]">messageId</span>
<span className="break-all">{debugDetails.messageId}</span>
<span className="text-[var(--color-text-muted)]">statusMessageId</span>
<span className="break-all">{debugDetails.statusMessageId}</span>
<span className="text-[var(--color-text-muted)]">providerId</span>
<span>{debugDetails.providerId}</span>
<span className="text-[var(--color-text-muted)]">delivered</span>
@ -128,8 +133,22 @@ export const OpenCodeDeliveryWarning = ({
<span>{debugDetails.responseState ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">ledgerStatus</span>
<span>{debugDetails.ledgerStatus ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">visibleReplyMessageId</span>
<span className="break-all">{debugDetails.visibleReplyMessageId ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">visibleReplyCorrelation</span>
<span>{debugDetails.visibleReplyCorrelation ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">queuedBehindMessageId</span>
<span className="break-all">{debugDetails.queuedBehindMessageId ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">acceptanceUnknown</span>
<span>{String(debugDetails.acceptanceUnknown)}</span>
<span className="text-[var(--color-text-muted)]">userVisibleState</span>
<span>{debugDetails.userVisibleState ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">userVisibleReasonCode</span>
<span>{debugDetails.userVisibleReasonCode ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">userVisibleNextReviewAt</span>
<span>{debugDetails.userVisibleNextReviewAt ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">userVisibleMessage</span>
<span>{debugDetails.userVisibleMessage ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">reason</span>
<span>{debugDetails.reason ?? 'null'}</span>
<span className="text-[var(--color-text-muted)]">diagnostics</span>

View file

@ -0,0 +1,193 @@
import {
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import type {
TaskChangeRequestOptions,
TeamTaskChangeSummaryRequest,
TeamTaskWithKanban,
} from '@shared/types';
export const TEAM_CHANGES_MAX_REQUESTS = 120;
export const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32;
export const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300;
interface TeamChangeCandidate {
task: TeamTaskWithKanban;
options: TaskChangeRequestOptions;
priority: number;
}
export interface TeamChangeRequestPlan {
requests: TeamTaskChangeSummaryRequest[];
requestOptionsByTaskId: Map<string, TaskChangeRequestOptions>;
eligibleTaskIds: Set<string>;
eligibleCount: number;
requestedCount: number;
deferredCount: number;
nextUnknownScanCursor: number;
}
export function getTeamChangeTaskTimeMs(task: TeamTaskWithKanban): number {
const value = task.updatedAt ?? task.createdAt;
if (!value) return 0;
const ms = new Date(value).getTime();
return Number.isFinite(ms) ? ms : 0;
}
function compareCandidateRecency(a: TeamChangeCandidate, b: TeamChangeCandidate): number {
const priorityDelta = a.priority - b.priority;
if (priorityDelta !== 0) return priorityDelta;
return getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task);
}
function rotateCandidates<T>(items: T[], cursor: number): T[] {
if (items.length === 0) return items;
const start = cursor % items.length;
if (start === 0) return items;
return [...items.slice(start), ...items.slice(0, start)];
}
function hasTaskChangeScanEvidence(task: TeamTaskWithKanban): boolean {
if ((task.workIntervals?.length ?? 0) > 0 || (task.reviewIntervals?.length ?? 0) > 0) {
return true;
}
return (
task.historyEvents?.some((event) => {
if (event.type === 'task_created') {
return false;
}
return event.type === 'status_changed' || event.type.startsWith('review_');
}) ?? false
);
}
function getRelevantHistoryEvents(task: TeamTaskWithKanban): { type: string; timestamp: string }[] {
return (
task.historyEvents
?.filter((event) => event.type === 'status_changed' || event.type.startsWith('review_'))
.map((event) => ({
type: event.type,
timestamp: event.timestamp,
})) ?? []
);
}
export function buildTeamChangeRequestPlan(
tasks: TeamTaskWithKanban[],
unknownScanCursor: number,
forceFresh: boolean
): TeamChangeRequestPlan {
const primary: TeamChangeCandidate[] = [];
const active: TeamChangeCandidate[] = [];
const unknown: TeamChangeCandidate[] = [];
const seenTaskIds = new Set<string>();
for (const task of tasks) {
if (!task.id || task.status === 'deleted' || seenTaskIds.has(task.id)) {
continue;
}
seenTaskIds.add(task.id);
const options = buildTaskChangeRequestOptions(task, { summaryOnly: true });
const presence = task.changePresence ?? 'unknown';
const canDisplay = canDisplayTaskChangesForOptions(options);
const shouldScanUnknown =
presence === 'unknown' && (canDisplay || hasTaskChangeScanEvidence(task));
if (
!canDisplay &&
presence !== 'has_changes' &&
presence !== 'needs_attention' &&
!shouldScanUnknown
) {
continue;
}
if (presence === 'has_changes') {
primary.push({ task, options, priority: 0 });
continue;
}
if (presence === 'needs_attention') {
primary.push({ task, options, priority: 1 });
continue;
}
if (options.stateBucket === 'active' && options.status === 'in_progress') {
active.push({ task, options, priority: 2 });
continue;
}
if (shouldScanUnknown) {
unknown.push({ task, options, priority: 3 });
}
}
primary.sort(compareCandidateRecency);
active.sort(compareCandidateRecency);
unknown.sort(compareCandidateRecency);
const eligibleTaskIds = new Set(
[...primary, ...active, ...unknown].map((candidate) => candidate.task.id)
);
const unknownWindow = rotateCandidates(unknown, unknownScanCursor).slice(
0,
TEAM_CHANGES_UNKNOWN_SCAN_LIMIT
);
const selected = [...primary, ...active, ...unknownWindow].slice(0, TEAM_CHANGES_MAX_REQUESTS);
const requestOptionsByTaskId = new Map<string, TaskChangeRequestOptions>();
const requests = selected.map((candidate) => {
const options = {
...candidate.options,
summaryOnly: true,
forceFresh: forceFresh ? true : candidate.options.forceFresh,
};
requestOptionsByTaskId.set(candidate.task.id, options);
return {
taskId: candidate.task.id,
options,
};
});
const eligibleCount = primary.length + active.length + unknown.length;
const nextUnknownScanCursor =
unknown.length > 0
? (unknownScanCursor + Math.min(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, unknown.length)) %
unknown.length
: 0;
return {
requests,
requestOptionsByTaskId,
eligibleTaskIds,
eligibleCount,
requestedCount: requests.length,
deferredCount: Math.max(0, eligibleCount - requests.length),
nextUnknownScanCursor,
};
}
export function buildTeamChangesTasksFingerprint(tasks: TeamTaskWithKanban[]): string {
return JSON.stringify(
tasks
.map((task) => ({
id: task.id,
status: task.status,
owner: task.owner ?? '',
updatedAt: task.updatedAt ?? '',
changePresence: task.changePresence ?? 'unknown',
reviewState: task.reviewState ?? '',
kanbanColumn: task.kanbanColumn ?? '',
workIntervals:
task.workIntervals?.map((interval) => ({
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
})) ?? [],
reviewIntervals:
task.reviewIntervals?.map((interval) => ({
reviewer: interval.reviewer,
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
})) ?? [],
historyEvents: getRelevantHistoryEvents(task),
}))
.sort((a, b) => a.id.localeCompare(b.id))
);
}

View file

@ -1,6 +1,9 @@
import { api } from '@renderer/api';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { buildOpenCodeRuntimeDeliveryDiagnostics } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import {
buildOpenCodeRuntimeDeliveryDiagnostics,
isOpenCodeRuntimeDeliveryHardUxFailure,
} from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import { normalizePath } from '@renderer/utils/pathNormalize';
import {
buildTaskChangePresenceKey,
@ -2425,7 +2428,10 @@ export interface TeamSlice {
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
lastSendMessageResult: SendMessageResult | null;
clearSendMessageRuntimeDiagnostics: (messageId?: string | null) => void;
refreshSendMessageRuntimeDeliveryStatus: (teamName: string, messageId: string) => Promise<void>;
refreshSendMessageRuntimeDeliveryStatus: (
teamName: string,
input: string | { messageId: string; statusMessageId?: string | null }
) => Promise<void>;
reviewActionError: string | null;
provisioningRuns: Record<string, TeamProvisioningProgress>;
/** Synthetic TeamSummary snapshots for teams currently being provisioned (before config.json exists). */
@ -4494,8 +4500,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const result = await unwrapIpc('team:sendMessage', () =>
api.teams.sendMessage(teamName, request)
);
const runtimeDeliveryFailed =
result.runtimeDelivery?.attempted === true && result.runtimeDelivery.delivered === false;
const runtimeDeliveryFailed = isOpenCodeRuntimeDeliveryHardUxFailure(result.runtimeDelivery);
const runtimeDeliveryDiagnostics = buildOpenCodeRuntimeDeliveryDiagnostics(result);
const optimisticMessage: InboxMessage = {
from: request.from ?? 'user',
@ -4563,14 +4568,29 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
});
},
refreshSendMessageRuntimeDeliveryStatus: async (teamName: string, messageId: string) => {
const normalizedMessageId = messageId.trim();
refreshSendMessageRuntimeDeliveryStatus: async (teamName, input) => {
const normalizedMessageId = typeof input === 'string' ? input.trim() : input.messageId.trim();
const statusMessageId =
typeof input === 'string'
? normalizedMessageId
: input.statusMessageId?.trim() || normalizedMessageId;
if (!normalizedMessageId) return;
if (get().sendMessageDebugDetails?.messageId !== normalizedMessageId) return;
const status = await unwrapIpc('team:getOpenCodeRuntimeDeliveryStatus', () =>
api.teams.getOpenCodeRuntimeDeliveryStatus(teamName, normalizedMessageId)
let status = await unwrapIpc('team:getOpenCodeRuntimeDeliveryStatus', () =>
api.teams.getOpenCodeRuntimeDeliveryStatus(teamName, statusMessageId)
);
if (!status) return;
if (statusMessageId !== normalizedMessageId) {
const blockerStillChecking =
status.userVisibleImpact?.state === 'checking' || status.responsePending === true;
if (!blockerStillChecking) {
const ownStatus = await unwrapIpc('team:getOpenCodeRuntimeDeliveryStatus', () =>
api.teams.getOpenCodeRuntimeDeliveryStatus(teamName, normalizedMessageId)
);
if (!ownStatus) return;
status = ownStatus;
}
}
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true,
messageId: normalizedMessageId,

View file

@ -2,14 +2,22 @@ import type { SendMessageResult } from '@shared/types';
export interface OpenCodeRuntimeDeliveryDebugDetails {
messageId: string;
statusMessageId?: string;
providerId: string;
delivered: boolean | null;
responsePending: boolean | null;
responseState: string | null;
ledgerStatus: string | null;
visibleReplyMessageId?: string | null;
visibleReplyCorrelation?: string | null;
queuedBehindMessageId?: string | null;
acceptanceUnknown: boolean | null;
reason: string | null;
diagnostics: string[];
userVisibleState?: string | null;
userVisibleReasonCode?: string | null;
userVisibleMessage?: string | null;
userVisibleNextReviewAt?: string | null;
}
interface OpenCodeRuntimeDeliveryDiagnostics {
@ -18,7 +26,9 @@ interface OpenCodeRuntimeDeliveryDiagnostics {
}
const PENDING_WARNING =
'OpenCode runtime delivery is still being checked. Message was saved and will be retried if needed.';
'OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.';
const PROOF_WARNING =
'OpenCode reply could not be verified. Message was saved to inbox, but no visible reply or task progress proof was found.';
const FAILED_WARNING =
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
@ -27,35 +37,36 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
if (!normalized) {
return '';
}
if (normalized === 'empty_assistant_turn') {
const normalizedLower = normalized.toLowerCase();
if (normalizedLower === 'empty_assistant_turn') {
return 'OpenCode returned an empty assistant turn.';
}
if (normalized === 'prompt_delivered_no_assistant_message') {
if (normalizedLower === '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'
normalizedLower === 'visible_reply_still_required' ||
normalizedLower === 'visible_reply_ack_only_still_requires_answer' ||
normalizedLower === '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'
normalizedLower === 'visible_reply_destination_not_found_yet' ||
normalizedLower === 'visible_reply_missing_relayofmessageid'
) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
}
if (normalized === 'visible_reply_missing_task_refs') {
if (normalizedLower === 'visible_reply_missing_task_refs') {
return 'OpenCode created a reply without the required taskRefs metadata.';
}
if (normalized === 'visible_reply_missing_task_refs_after_merge') {
if (normalizedLower === 'visible_reply_missing_task_refs_after_merge') {
return 'OpenCode created a reply without the required taskRefs metadata.';
}
if (normalized === 'visible_reply_task_refs_merge_failed') {
if (normalizedLower === 'visible_reply_task_refs_merge_failed') {
return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.';
}
if (normalized === 'non_visible_tool_without_task_progress') {
if (normalizedLower === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
return '';
@ -69,27 +80,42 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
return { warning: null, debugDetails: null };
}
const isFailed = runtimeDelivery.delivered === false;
const isPending = runtimeDelivery.responsePending === true;
const userVisibleState = runtimeDelivery.userVisibleImpact?.state;
const isFailed =
userVisibleState === 'error' || (!userVisibleState && runtimeDelivery.delivered === false);
const isWarning = userVisibleState === 'warning';
const isPending =
userVisibleState === 'checking' ||
(!userVisibleState && runtimeDelivery.responsePending === true);
if (!isFailed && !isPending) {
return { warning: null, debugDetails: null };
if (!isWarning) {
return { warning: null, debugDetails: null };
}
}
const failureReason = isFailed
? formatOpenCodeRuntimeDeliveryFailureReason(
runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0]
)
: '';
const userVisibleMessage = runtimeDelivery.userVisibleImpact?.message?.trim();
const failureReason =
isFailed || isWarning
? formatOpenCodeRuntimeDeliveryFailureReason(
userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0]
)
: '';
const statusMessageId = runtimeDelivery.queuedBehindMessageId ?? result.messageId;
return {
warning:
isFailed && failureReason
? `${FAILED_WARNING} Reason: ${failureReason}`
: isFailed
? FAILED_WARNING
: PENDING_WARNING,
isWarning && failureReason
? `${PROOF_WARNING} Reason: ${failureReason}`
: isWarning
? PROOF_WARNING
: isFailed && failureReason
? `${FAILED_WARNING} Reason: ${failureReason}`
: isFailed
? FAILED_WARNING
: PENDING_WARNING,
debugDetails: {
messageId: result.messageId,
statusMessageId,
providerId: runtimeDelivery.providerId,
delivered: typeof runtimeDelivery.delivered === 'boolean' ? runtimeDelivery.delivered : null,
responsePending:
@ -98,30 +124,86 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
: null,
responseState: runtimeDelivery.responseState ?? null,
ledgerStatus: runtimeDelivery.ledgerStatus ?? null,
visibleReplyMessageId: runtimeDelivery.visibleReplyMessageId ?? null,
visibleReplyCorrelation: runtimeDelivery.visibleReplyCorrelation ?? null,
queuedBehindMessageId: runtimeDelivery.queuedBehindMessageId ?? null,
acceptanceUnknown:
typeof runtimeDelivery.acceptanceUnknown === 'boolean'
? runtimeDelivery.acceptanceUnknown
: null,
reason: runtimeDelivery.reason ?? null,
diagnostics: runtimeDelivery.diagnostics ?? [],
userVisibleState: runtimeDelivery.userVisibleImpact?.state ?? null,
userVisibleReasonCode: runtimeDelivery.userVisibleImpact?.reasonCode ?? null,
userVisibleMessage: runtimeDelivery.userVisibleImpact?.message ?? null,
userVisibleNextReviewAt: runtimeDelivery.userVisibleImpact?.nextReviewAt ?? null,
},
};
}
export function isOpenCodeRuntimeDeliveryHardUxFailure(
runtimeDelivery: SendMessageResult['runtimeDelivery'] | null | undefined
): boolean {
if (runtimeDelivery?.attempted !== true) {
return false;
}
const userVisibleState = runtimeDelivery.userVisibleImpact?.state;
if (userVisibleState) {
return userVisibleState === 'error';
}
return runtimeDelivery.delivered === false;
}
export function isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails(
details: OpenCodeRuntimeDeliveryDebugDetails | null | undefined
): boolean {
if (!details) {
return false;
}
if (details.userVisibleState) {
return details.userVisibleState === 'error';
}
return details.delivered === false;
}
export function shouldClearPendingReplyForOpenCodeRuntimeDelivery(
runtimeDelivery: SendMessageResult['runtimeDelivery'] | null | undefined
): boolean {
if (runtimeDelivery?.attempted !== true) {
return false;
}
const userVisibleState = runtimeDelivery.userVisibleImpact?.state;
if (userVisibleState === 'warning' || userVisibleState === 'error') {
return true;
}
if (userVisibleState === 'checking') {
return false;
}
return runtimeDelivery.responsePending !== true;
}
export function formatOpenCodeRuntimeDeliveryDebugDetails(
details: OpenCodeRuntimeDeliveryDebugDetails
): string {
return JSON.stringify(
{
messageId: details.messageId,
statusMessageId: details.statusMessageId,
providerId: details.providerId,
delivered: details.delivered,
responsePending: details.responsePending,
responseState: details.responseState,
ledgerStatus: details.ledgerStatus,
visibleReplyMessageId: details.visibleReplyMessageId,
visibleReplyCorrelation: details.visibleReplyCorrelation,
queuedBehindMessageId: details.queuedBehindMessageId,
acceptanceUnknown: details.acceptanceUnknown,
reason: details.reason,
diagnostics: details.diagnostics,
userVisibleState: details.userVisibleState,
userVisibleReasonCode: details.userVisibleReasonCode,
userVisibleMessage: details.userVisibleMessage,
userVisibleNextReviewAt: details.userVisibleNextReviewAt,
},
null,
2

View file

@ -755,6 +755,7 @@ export interface SendMessageResult {
queuedBehindMessageId?: string;
reason?: string;
diagnostics?: string[];
userVisibleImpact?: OpenCodeRuntimeDeliveryUserVisibleImpact;
};
}
@ -855,6 +856,16 @@ export interface MemberRuntimeAdvisory {
statusCode?: number;
}
export type OpenCodeRuntimeDeliveryUserVisibleState = 'none' | 'checking' | 'warning' | 'error';
export interface OpenCodeRuntimeDeliveryUserVisibleImpact {
state: OpenCodeRuntimeDeliveryUserVisibleState;
reasonCode?: MemberRuntimeAdvisory['reasonCode'];
message?: string;
observedAt?: string;
nextReviewAt?: string;
}
export interface TeamProcess {
id: string;
port?: number;

View file

@ -384,6 +384,40 @@ describe('ChangeExtractorService', () => {
});
});
it('deduplicates team task change summary requests before loading', async () => {
const { service } = createService({ logPaths: [] });
const getTaskChanges = vi
.spyOn(service, 'getTaskChanges')
.mockImplementation(async (_teamName, taskId) => makeTaskChangeResult(taskId, { taskId }));
const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
{ taskId: 'task-1', options: SUMMARY_OPTIONS },
{ taskId: 'task-1', options: SUMMARY_OPTIONS },
{ taskId: ' task-2 ', options: SUMMARY_OPTIONS },
]);
expect(response.items.map((item) => item.taskId)).toEqual(['task-1', 'task-2']);
expect(response.truncated).toBeUndefined();
expect(getTaskChanges).toHaveBeenCalledTimes(2);
});
it('ignores malformed team task change summary requests', async () => {
const { service } = createService({ logPaths: [] });
const getTaskChanges = vi
.spyOn(service, 'getTaskChanges')
.mockImplementation(async (_teamName, taskId) => makeTaskChangeResult(taskId, { taskId }));
const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
null,
{ taskId: '' },
{ taskId: 'task-1', options: SUMMARY_OPTIONS },
{ taskId: 42 },
] as unknown as Parameters<typeof service.getTeamTaskChangeSummaries>[1]);
expect(response.items.map((item) => item.taskId)).toEqual(['task-1']);
expect(getTaskChanges).toHaveBeenCalledTimes(1);
});
it('does not reuse detailed task-change cache across different scope inputs', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import {
decideOpenCodeRuntimeDeliveryAdvisory,
OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS,
} from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy';
import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
function makeRecord(
overrides: Partial<OpenCodePromptDeliveryLedgerRecord>
): OpenCodePromptDeliveryLedgerRecord {
const now = '2026-05-09T12:00:00.000Z';
return {
id: 'opencode-prompt:test',
teamName: 'team',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
runId: 'run-1',
runtimeSessionId: 'session-1',
inboxMessageId: 'msg-1',
inboxTimestamp: now,
source: 'ui-send',
messageKind: null,
replyRecipient: 'user',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'empty_assistant_turn',
attempts: 3,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: now,
lastObservedAt: now,
acceptedAt: now,
respondedAt: now,
failedAt: now,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'delivered-1',
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe('OpenCodeRuntimeDeliveryAdvisoryPolicy', () => {
it('defers fresh generic terminal failures for proof observation', () => {
const record = makeRecord({});
const decision = decideOpenCodeRuntimeDeliveryAdvisory({
record,
now: Date.parse(record.failedAt!) + 1_000,
});
expect(decision).toMatchObject({
action: 'defer',
reasonCode: 'backend_error',
nextReviewAt: new Date(
Date.parse(record.failedAt!) + OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS
).toISOString(),
});
});
it('surfaces action-required failures immediately', () => {
const record = makeRecord({
responseState: 'permission_blocked',
lastReason: 'authentication_failed',
diagnostics: ['authentication_failed'],
});
expect(
decideOpenCodeRuntimeDeliveryAdvisory({
record,
now: Date.parse(record.failedAt!) + 1_000,
})
).toMatchObject({
action: 'surface',
severity: 'error',
reasonCode: 'auth_error',
});
});
it('suppresses generic retryable tool errors before terminal failure', () => {
const record = makeRecord({
status: 'failed_retryable',
responseState: 'tool_error',
failedAt: null,
nextAttemptAt: '2026-05-09T12:00:30.000Z',
lastReason: 'opencode bridge command timed out',
diagnostics: ['opencode bridge command timed out'],
});
expect(
decideOpenCodeRuntimeDeliveryAdvisory({
record,
now: Date.parse(record.updatedAt) + 1_000,
})
).toMatchObject({ action: 'suppress' });
});
it('surfaces permission-blocked retryable failures before terminal failure', () => {
const record = makeRecord({
status: 'failed_retryable',
responseState: 'permission_blocked',
failedAt: null,
nextAttemptAt: '2026-05-09T12:00:30.000Z',
lastReason: 'authentication_failed',
diagnostics: ['authentication_failed'],
});
expect(
decideOpenCodeRuntimeDeliveryAdvisory({
record,
now: Date.parse(record.updatedAt) + 1_000,
})
).toMatchObject({
action: 'surface',
severity: 'error',
reasonCode: 'auth_error',
});
});
it('suppresses terminal failures when visible proof already exists', () => {
const record = makeRecord({});
expect(
decideOpenCodeRuntimeDeliveryAdvisory({
record,
proof: {
visibleReplyAt: Date.parse(record.failedAt!) + 1_000,
visibleReplyMessageId: 'reply-1',
visibleReplyInbox: 'user',
},
now: Date.parse(record.failedAt!) + OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS + 1,
})
).toMatchObject({ action: 'suppress' });
});
});

View file

@ -170,10 +170,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
['codex_native_timeout', 'Codex native exec timed out after 120000ms.'],
['network_error', 'Fetch failed because the network connection timed out.'],
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
[
'protocol_proof_missing',
'OpenCode created a reply without the required taskRefs metadata.',
],
['protocol_proof_missing', 'OpenCode created a reply without the required taskRefs metadata.'],
['backend_error', 'Unexpected backend blew up during request processing.'],
] as const)('classifies %s retry causes from api_error messages', async (expected, message) => {
const service = new TeamMemberRuntimeAdvisoryService({} as never);
@ -372,6 +369,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
const teamName = 'relay-works';
const laneId = 'secondary:opencode:jack';
const nowIso = new Date().toISOString();
const oldIso = new Date(Date.now() - 3 * 60 * 1000).toISOString();
const laneDir = path.join(
tmpDir,
'teams',
@ -396,7 +394,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
JSON.stringify({
schemaVersion: 1,
updatedAt: nowIso,
updatedAt: oldIso,
data: [
{
id: 'opencode-prompt:proof-missing',
@ -406,7 +404,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'msg-1',
inboxTimestamp: nowIso,
inboxTimestamp: oldIso,
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
@ -419,11 +417,11 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: nowIso,
lastObservedAt: nowIso,
acceptedAt: nowIso,
respondedAt: nowIso,
failedAt: nowIso,
lastAttemptAt: oldIso,
lastObservedAt: oldIso,
acceptedAt: oldIso,
respondedAt: oldIso,
failedAt: oldIso,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
@ -438,8 +436,8 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
visibleReplyCorrelation: null,
lastReason: 'non_visible_tool_without_task_progress',
diagnostics: ['non_visible_tool_without_task_progress'],
createdAt: nowIso,
updatedAt: nowIso,
createdAt: oldIso,
updatedAt: oldIso,
},
],
}),
@ -866,10 +864,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
]);
expect(logsFinder.findRecentMemberLogFileRefsByMember).toHaveBeenCalledTimes(1);
expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual([
'Alice',
'Bob',
]);
expect(logsFinder.findMemberLogs.mock.calls.map((call) => call[1])).toEqual(['Alice', 'Bob']);
expect(Array.from(advisories.keys())).toEqual(['Alice', 'Bob']);
});

View file

@ -671,10 +671,10 @@ describe('MessagesPanel idle summary invariants', () => {
await Promise.resolve();
});
expect(storeState.refreshSendMessageRuntimeDeliveryStatus).toHaveBeenCalledWith(
'atlas-hq',
'user-send'
);
expect(storeState.refreshSendMessageRuntimeDeliveryStatus).toHaveBeenCalledWith('atlas-hq', {
messageId: 'user-send',
statusMessageId: 'user-send',
});
await act(async () => {
root.unmount();

View file

@ -721,6 +721,62 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(actions.useModelForNewTeams).not.toHaveBeenCalled();
});
it('keeps the model search input enabled while model results are loading', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
const connectedProvider = {
...createState().view!.providers[0],
state: 'connected' as const,
ownership: ['managed'] as const,
modelCount: 174,
actions: [
{
id: 'use' as const,
label: 'Use',
enabled: true,
disabledReason: null,
requiresSecret: false,
ownershipScope: 'runtime' as const,
},
],
};
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
view: {
...createState().view!,
providers: [connectedProvider],
},
providers: [connectedProvider],
selectedProviderId: 'openrouter',
modelPickerProviderId: 'openrouter',
modelPickerMode: 'use',
modelQuery: 'claude',
modelsLoading: true,
}),
actions,
disabled: false,
})
);
await Promise.resolve();
});
const searchInput = host.querySelector(
'[data-testid="runtime-provider-model-search"]'
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
expect(searchInput?.disabled).toBe(false);
expect(searchInput?.value).toBe('claude');
expect(host.querySelector('[data-testid="runtime-provider-model-loading-skeleton"]')).not.toBe(
null
);
});
it('keeps directory provider models visible when a model row is selected', async () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -477,7 +477,7 @@ describe('teamSlice actions', () => {
expect(store.getState().lastSendMessageResult).toBe(result);
expect(store.getState().sendMessageWarning).toBe(
'OpenCode runtime delivery is still being checked. Message was saved and will be retried if needed.'
'OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.'
);
expect(store.getState().sendMessageDebugDetails).toMatchObject({
messageId: 'm-opencode-pending',
@ -567,7 +567,7 @@ describe('teamSlice actions', () => {
store.getState().clearSendMessageRuntimeDiagnostics('other-message');
expect(store.getState().sendMessageWarning).toBe(
'OpenCode runtime delivery is still being checked. Message was saved and will be retried if needed.'
'OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.'
);
expect(store.getState().sendMessageDebugDetails?.messageId).toBe('m-opencode-pending');

View file

@ -3,6 +3,61 @@ import { describe, expect, it } from 'vitest';
import { buildOpenCodeRuntimeDeliveryDiagnostics } from '../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics';
describe('openCodeRuntimeDeliveryDiagnostics', () => {
it('honors user-visible checking impact over raw terminal delivery facts', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true,
messageId: 'msg-empty',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'empty_assistant_turn',
ledgerStatus: 'failed_terminal',
reason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
userVisibleImpact: {
state: 'checking',
reasonCode: 'backend_error',
message: 'empty_assistant_turn',
nextReviewAt: '2026-05-09T12:00:00.000Z',
},
},
});
expect(diagnostics.warning).toBe(
'OpenCode delivery is still being checked. Message was saved and will be observed before retry if needed.'
);
expect(diagnostics.debugDetails).toMatchObject({
messageId: 'msg-empty',
statusMessageId: 'msg-empty',
userVisibleState: 'checking',
userVisibleNextReviewAt: '2026-05-09T12:00:00.000Z',
});
});
it('honors user-visible none impact over raw terminal delivery facts', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true,
messageId: 'msg-proven',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'empty_assistant_turn',
ledgerStatus: 'failed_terminal',
reason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
userVisibleImpact: {
state: 'none',
},
},
});
expect(diagnostics).toEqual({ warning: null, debugDetails: null });
});
it('surfaces terminal empty assistant turn in the compact failed warning', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true,