feat(team): harden opencode delivery advisories
This commit is contained in:
parent
a9e7c59845
commit
8fd8949684
34 changed files with 4291 additions and 769 deletions
2078
docs/team-management/member-work-sync-review-obligation-plan.md
Normal file
2078
docs/team-management/member-work-sync-review-obligation-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'>[]
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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]) } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
193
src/renderer/components/team/teamChangesRequestPlan.ts
Normal file
193
src/renderer/components/team/teamChangesRequestPlan.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue