From 2e6549620f2188c8083ff229295f50f279ebb06f Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 19:19:43 +0300 Subject: [PATCH] feat(member-work-sync): add nudge activation policy --- .../MemberWorkSyncNudgeActivationPolicy.ts | 58 ++++ .../MemberWorkSyncNudgeDispatcher.ts | 69 +++- .../MemberWorkSyncNudgeOutboxPlanner.ts | 4 +- .../core/application/index.ts | 1 + .../core/application/ports.ts | 13 + .../createMemberWorkSyncFeature.ts | 19 +- .../CompositeMemberWorkSyncBusySignal.ts | 35 ++ .../services/team/TeamProvisioningService.ts | 140 ++++++-- .../OpenCodePromptDeliveryRepairPolicy.ts | 314 ++++++++++++++++++ 9 files changed, 609 insertions(+), 44 deletions(-) create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts create mode 100644 src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts create mode 100644 src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts new file mode 100644 index 00000000..7f14c441 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -0,0 +1,58 @@ +import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts'; + +export type MemberWorkSyncNudgeActivationReason = + | 'shadow_ready' + | 'opencode_targeted_shadow_collecting' + | 'status_not_nudgeable' + | 'blocking_metrics' + | 'phase2_not_ready'; + +export interface MemberWorkSyncNudgeActivationDecision { + active: boolean; + reason: MemberWorkSyncNudgeActivationReason; +} + +const BLOCKING_PHASE2_REASONS = new Set([ + 'would_nudge_rate_high', + 'fingerprint_churn_high', + 'report_rejection_rate_high', +]); + +function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean { + return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason)); +} + +function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean { + return ( + status.providerId === 'opencode' && + status.state === 'needs_sync' && + status.agenda.items.length > 0 && + status.shadow?.wouldNudge === true + ); +} + +export function decideMemberWorkSyncNudgeActivation(input: { + status: MemberWorkSyncStatus; + metrics: MemberWorkSyncTeamMetrics; +}): MemberWorkSyncNudgeActivationDecision { + if (input.status.state !== 'needs_sync' || input.status.agenda.items.length === 0) { + return { active: false, reason: 'status_not_nudgeable' }; + } + + if (hasBlockingMetrics(input.metrics)) { + return { active: false, reason: 'blocking_metrics' }; + } + + if (input.metrics.phase2Readiness.state === 'shadow_ready') { + return { active: true, reason: 'shadow_ready' }; + } + + if ( + input.metrics.phase2Readiness.state === 'collecting_shadow_data' && + isOpenCodeTargetedCandidate(input.status) + ) { + return { active: true, reason: 'opencode_targeted_shadow_collecting' }; + } + + return { active: false, reason: 'phase2_not_ready' }; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index c41fdd28..0b74ce17 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -1,8 +1,9 @@ import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit'; +import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy'; import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler'; import { decideMemberWorkSyncStatus } from '../domain'; -import type { MemberWorkSyncOutboxItem } from '../../contracts'; +import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts'; import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports'; const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; @@ -151,6 +152,12 @@ export class MemberWorkSyncNudgeDispatcher { nowIso, }); await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted'); + await this.scheduleDeliveryWake( + item, + inserted.messageId, + inserted.inserted, + revalidation.providerId + ); return 'delivered'; } catch (error) { await outbox.markFailed({ @@ -188,7 +195,8 @@ export class MemberWorkSyncNudgeDispatcher { item: MemberWorkSyncOutboxItem, nowIso: string ): Promise< - { ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } + | { ok: true; providerId?: MemberWorkSyncStatus['providerId'] } + | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } > { const teamActive = this.deps.lifecycle ? await this.deps.lifecycle.isTeamActive(item.teamName) @@ -221,6 +229,24 @@ export class MemberWorkSyncNudgeDispatcher { nowIso, inactive: source.inactive || !teamActive, }); + const providerId = source.providerId ?? previous.providerId; + const revalidatedStatus: MemberWorkSyncStatus = { + ...previous, + state: decision.state, + agenda, + ...(decision.acceptedReport ? { report: decision.acceptedReport } : {}), + shadow: { + ...previous.shadow, + reconciledBy: previous.shadow?.reconciledBy ?? 'queue', + wouldNudge: decision.state === 'needs_sync' && agenda.items.length > 0, + fingerprintChanged: + Boolean(previous.agenda.fingerprint) && + previous.agenda.fingerprint !== agenda.fingerprint, + }, + evaluatedAt: nowIso, + diagnostics: [...agenda.diagnostics, ...decision.diagnostics], + ...(providerId ? { providerId } : {}), + }; if ( decision.state !== 'needs_sync' || agenda.items.length === 0 || @@ -233,7 +259,11 @@ export class MemberWorkSyncNudgeDispatcher { return { ok: false, reason: 'metrics_unavailable', retryable: true }; } const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName); - if (metrics.phase2Readiness.state !== 'shadow_ready') { + const activation = decideMemberWorkSyncNudgeActivation({ + status: revalidatedStatus, + metrics, + }); + if (!activation.active) { return { ok: false, reason: 'phase2_not_ready', retryable: true }; } @@ -281,6 +311,37 @@ export class MemberWorkSyncNudgeDispatcher { return { ok: false, reason: 'watchdog_cooldown_active', retryable: true }; } - return { ok: true }; + return { ok: true, ...(providerId ? { providerId } : {}) }; + } + + private async scheduleDeliveryWake( + item: MemberWorkSyncOutboxItem, + messageId: string, + inserted: boolean, + providerId?: MemberWorkSyncStatus['providerId'] + ): Promise { + if (!this.deps.nudgeDeliveryWake) { + return; + } + + try { + await this.deps.nudgeDeliveryWake.schedule({ + teamName: item.teamName, + memberName: item.memberName, + messageId, + ...(providerId ? { providerId } : {}), + reason: inserted ? 'member_work_sync_nudge_inserted' : 'member_work_sync_nudge_existing', + delayMs: 500, + }); + } catch (error) { + const reason = `nudge_wake_failed:${String(error)}`; + await this.appendDispatchAudit(item, 'nudge_wake_failed', reason); + this.deps.logger?.warn('member work sync nudge delivery wake failed', { + teamName: item.teamName, + memberName: item.memberName, + messageId, + error: String(error), + }); + } } } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts index ab01079e..23129a6e 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts @@ -1,6 +1,7 @@ import { buildMemberWorkSyncOutboxEnsureInput } from '../domain'; import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit'; +import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy'; import type { MemberWorkSyncStatus } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; @@ -38,7 +39,8 @@ export class MemberWorkSyncNudgeOutboxPlanner { } const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName); - if (metrics.phase2Readiness.state !== 'shadow_ready') { + const activation = decideMemberWorkSyncNudgeActivation({ status, metrics }); + if (!activation.active) { await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' }); return { planned: false, code: 'phase2_not_ready' }; } diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 94bd511d..b1f2c6d6 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -1,6 +1,7 @@ export * from './MemberWorkSyncAudit'; export * from './MemberWorkSyncDiagnosticsReader'; export * from './MemberWorkSyncMetricsReader'; +export * from './MemberWorkSyncNudgeActivationPolicy'; export * from './MemberWorkSyncNudgeDispatcher'; export * from './MemberWorkSyncNudgeOutboxPlanner'; export * from './MemberWorkSyncPendingReportIntentReplayer'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 886cb178..b7d06c74 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -82,6 +82,7 @@ export type MemberWorkSyncAuditEventName = | 'report_rejected' | 'nudge_planned' | 'nudge_delivered' + | 'nudge_wake_failed' | 'nudge_skipped' | 'nudge_retryable' | 'nudge_superseded' @@ -181,6 +182,17 @@ export interface MemberWorkSyncBusySignalPort { }): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>; } +export interface MemberWorkSyncNudgeDeliveryWakePort { + schedule(input: { + teamName: string; + memberName: string; + messageId: string; + providerId?: MemberWorkSyncProviderId | null; + reason: 'member_work_sync_nudge_inserted' | 'member_work_sync_nudge_existing'; + delayMs?: number; + }): Promise | void; +} + export interface MemberWorkSyncUseCaseDeps { clock: MemberWorkSyncClockPort; hash: MemberWorkSyncHashPort; @@ -191,6 +203,7 @@ export interface MemberWorkSyncUseCaseDeps { inboxNudge?: MemberWorkSyncInboxNudgePort; watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort; busySignal?: MemberWorkSyncBusySignalPort; + nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort; reportToken?: MemberWorkSyncReportTokenPort; auditJournal?: MemberWorkSyncAuditJournalPort; lifecycle?: MemberWorkSyncLifecyclePort; diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 77f0e4d9..140d2b6b 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -20,6 +20,7 @@ import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown'; import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer'; import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer'; +import { CompositeMemberWorkSyncBusySignal } from '../infrastructure/CompositeMemberWorkSyncBusySignal'; import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer'; import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal'; import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; @@ -46,7 +47,11 @@ import type { MemberWorkSyncStatusRequest, MemberWorkSyncTeamMetrics, } from '../../contracts'; -import type { MemberWorkSyncLoggerPort } from '../../core/application'; +import type { + MemberWorkSyncBusySignalPort, + MemberWorkSyncLoggerPort, + MemberWorkSyncNudgeDeliveryWakePort, +} from '../../core/application'; import type { RuntimeTurnSettledProvider } from '../../core/domain'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; @@ -93,6 +98,8 @@ export function createMemberWorkSyncFeature(deps: { listLifecycleActiveTeamNames?: () => Promise; queueQuietWindowMs?: number; runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort; + extraBusySignals?: MemberWorkSyncBusySignalPort[]; + nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -138,7 +145,12 @@ export function createMemberWorkSyncFeature(deps: { }); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath); - const busySignal = new MemberWorkSyncToolActivityBusySignal(); + const toolActivityBusySignal = new MemberWorkSyncToolActivityBusySignal(); + const busySignals = [toolActivityBusySignal, ...(deps.extraBusySignals ?? [])]; + const busySignal = + busySignals.length === 1 + ? toolActivityBusySignal + : new CompositeMemberWorkSyncBusySignal(busySignals, deps.logger); const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink(); const useCaseDeps = { clock, @@ -150,6 +162,7 @@ export function createMemberWorkSyncFeature(deps: { inboxNudge, watchdogCooldown, busySignal, + ...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}), reportToken, auditJournal, ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), @@ -233,7 +246,7 @@ export function createMemberWorkSyncFeature(deps: { getMetrics: (request) => metricsReader.execute(request), report: (request) => reporter.execute(request), noteTeamChange: (event) => { - busySignal.noteTeamChange(event); + toolActivityBusySignal.noteTeamChange(event); router.noteTeamChange(event); }, enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames), diff --git a/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts new file mode 100644 index 00000000..c53549be --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts @@ -0,0 +1,35 @@ +import type { + MemberWorkSyncBusySignalPort, + MemberWorkSyncLoggerPort, +} from '../../core/application'; + +export class CompositeMemberWorkSyncBusySignal implements MemberWorkSyncBusySignalPort { + constructor( + private readonly signals: MemberWorkSyncBusySignalPort[], + private readonly logger?: MemberWorkSyncLoggerPort + ) {} + + async isBusy(input: Parameters[0]) { + for (const signal of this.signals) { + try { + const result = await signal.isBusy(input); + if (result.busy) { + return result; + } + } catch (error) { + this.logger?.warn('member work sync busy signal failed', { + teamName: input.teamName, + memberName: input.memberName, + error: String(error), + }); + return { + busy: true, + reason: 'busy_signal_error', + retryAfterIso: new Date(Date.parse(input.nowIso) + 60_000).toISOString(), + }; + } + } + + return { busy: false }; + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e7d00a41..b9012103 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -163,6 +163,10 @@ import { type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryStatus, } from './opencode/delivery/OpenCodePromptDeliveryLedger'; +import { + decideOpenCodePromptDeliveryRepair, + type OpenCodePromptDeliveryHardFailureKind, +} from './opencode/delivery/OpenCodePromptDeliveryRepairPolicy'; import { isOpenCodePromptDeliveryObserveLaterResponseState, isOpenCodePromptDeliveryRetryableResponseState, @@ -5192,6 +5196,18 @@ function normalizeSameTeamText(text: string): string { return text.trim().replace(/\r\n/g, '\n'); } +function getOpenCodeInboxRelayPriority( + message: Pick +): number { + if (message.messageKind === 'member_work_sync_nudge') { + return 30; + } + if (message.source === 'system_notification') { + return 20; + } + return 0; +} + export class TeamProvisioningService { private readonly runtimeLaneCoordinator = createTeamRuntimeLaneCoordinator(); private readonly providerConnectionService = ProviderConnectionService.getInstance(); @@ -6713,6 +6729,9 @@ export class TeamProvisioningService { if (state === 'prompt_delivered_no_assistant_message') { return 'prompt_delivered_no_assistant_message'; } + if (state === 'tool_error') { + return 'tool_error_without_required_delivery_proof'; + } return record?.lastReason ?? 'opencode_delivery_response_pending'; } @@ -6775,40 +6794,62 @@ export class TeamProvisioningService { return false; } - private buildOpenCodePromptDeliveryAttemptText(input: { - ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; - text: string; - replyRecipient: string; - }): string { - const record = input.ledgerRecord; - if (!record || record.status === 'pending' || record.attempts <= 0) { - return input.text; + private getOpenCodeDeliveryHardFailureKind( + record?: OpenCodePromptDeliveryLedgerRecord | null + ): OpenCodePromptDeliveryHardFailureKind { + if (!record) { + return 'none'; } - const visibleAnswerRequired = - record.lastReason === 'visible_reply_still_required' || - record.lastReason === 'plain_text_ack_only_still_requires_answer' || - (record.responseState === 'responded_non_visible_tool' && - record.actionMode === 'ask' && - record.taskRefs.length === 0); - const attemptNumber = Math.min(record.attempts + 1, record.maxAttempts); - const header = visibleAnswerRequired - ? [ - '', - `This is retry attempt ${attemptNumber}/${record.maxAttempts} for inbound app messageId "${record.inboxMessageId}".`, - `You accepted the earlier prompt but did not provide a visible/concrete answer for the recipient "${input.replyRecipient}".`, - `Please reply with agent-teams_message_send to "${input.replyRecipient}" and include relayOfMessageId="${record.inboxMessageId}". If that tool is unavailable, provide a concise plain-text answer.`, - 'Do not repeat tool work unless needed and do not reply only with acknowledgement.', - '', - ] - : [ - '', - `This is retry attempt ${attemptNumber}/${record.maxAttempts} for inbound app messageId "${record.inboxMessageId}".`, - 'The previous OpenCode turn was accepted, but the app still has no sufficient response proof for this message.', - `If you already acted on this message, do not duplicate work; send a concrete status via agent-teams_message_send with relayOfMessageId="${record.inboxMessageId}" or update the related task.`, - 'Do not reply only with acknowledgement.', - '', - ]; - return `${header.join('\n')}\n\n${input.text}`; + if (record.status === 'failed_terminal') { + return 'unknown'; + } + if (record.responseState === 'permission_blocked') { + return 'permission'; + } + if (record.responseState === 'session_error') { + return 'session'; + } + return 'none'; + } + + private buildOpenCodePromptDeliveryRepairControlText(input: { + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; + readAllowed: boolean; + pendingReason: string; + }): string | null { + const record = input.ledgerRecord; + if (!record) { + return null; + } + return decideOpenCodePromptDeliveryRepair({ + teamName: record.teamName, + memberName: record.memberName, + inboxMessageId: record.inboxMessageId, + replyRecipient: record.replyRecipient, + messageKind: record.messageKind, + actionMode: record.actionMode, + taskRefs: record.taskRefs, + status: record.status, + responseState: record.responseState, + attempts: record.attempts, + maxAttempts: record.maxAttempts, + pendingReason: input.pendingReason, + readAllowed: input.readAllowed, + inboxReadCommitted: Boolean(record.inboxReadCommittedAt), + visibleReplyFound: Boolean(record.visibleReplyMessageId), + hasKnownProgressProof: this.hasOpenCodeNonVisibleProgressProof(record), + toolCallNames: record.observedToolCallNames, + acceptanceUnknown: record.acceptanceUnknown, + hardFailureKind: this.getOpenCodeDeliveryHardFailureKind(record), + }).controlText; + } + + private buildOpenCodePromptDeliveryAttemptText(input: { + text: string; + controlText?: string | null; + }): string { + const controlText = input.controlText?.trim(); + return controlText ? `${controlText}\n\n${input.text}` : input.text; } private isOpenCodePromptAcceptanceUnknownFailure(diagnostics: readonly string[]): boolean { @@ -8182,10 +8223,31 @@ export class TeamProvisioningService { } } + const retryReadAllowed = ledgerRecord + ? this.isOpenCodeDeliveryResponseReadCommitAllowed({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode ?? undefined, + taskRefs: ledgerRecord.taskRefs, + visibleReply: null, + ledgerRecord, + }) + : false; + const retryPendingReason = ledgerRecord + ? this.getOpenCodeDeliveryPendingReason({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode, + taskRefs: ledgerRecord.taskRefs, + visibleReply: null, + ledgerRecord, + }) + : 'opencode_delivery_response_pending'; const deliveryText = this.buildOpenCodePromptDeliveryAttemptText({ - ledgerRecord, text: input.text, - replyRecipient: input.replyRecipient ?? ledgerRecord?.replyRecipient ?? 'user', + controlText: this.buildOpenCodePromptDeliveryRepairControlText({ + ledgerRecord, + readAllowed: retryReadAllowed, + pendingReason: retryPendingReason, + }), }); const result = await adapter.sendMessageToMember({ ...(runtimeRunId ? { runId: runtimeRunId } : {}), @@ -18663,7 +18725,13 @@ export class TeamProvisioningService { if (typeof message.text !== 'string' || message.text.trim().length === 0) return false; return this.hasStableMessageId(message); }) - .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)) + .sort((a, b) => { + const priorityDelta = getOpenCodeInboxRelayPriority(a) - getOpenCodeInboxRelayPriority(b); + if (priorityDelta !== 0) return priorityDelta; + const timeDelta = Date.parse(a.timestamp) - Date.parse(b.timestamp); + if (timeDelta !== 0) return timeDelta; + return a.messageId.localeCompare(b.messageId); + }) .slice(0, 10); for (const message of unread) { diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts new file mode 100644 index 00000000..cda1d47b --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts @@ -0,0 +1,314 @@ +import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract'; +import type { + OpenCodePromptDeliveryLedgerRecord, + OpenCodePromptDeliveryStatus, +} from './OpenCodePromptDeliveryLedger'; +import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team'; + +export type OpenCodePromptDeliveryRepairKind = + | 'none' + | 'no_assistant_response' + | 'visible_answer_required' + | 'missing_visible_reply_correlation' + | 'work_sync_report_required' + | 'progress_proof_required' + | 'app_materialization_pending'; + +export type OpenCodePromptDeliveryHardFailureKind = 'none' | 'session' | 'permission' | 'unknown'; + +export interface OpenCodePromptDeliveryRepairDecision { + kind: OpenCodePromptDeliveryRepairKind; + retryable: boolean; + controlText: string | null; + reason: string; +} + +export interface OpenCodePromptDeliveryRepairInput { + teamName: string; + memberName: string; + inboxMessageId: string; + replyRecipient: string; + messageKind: InboxMessageKind | null; + actionMode: AgentActionMode | null; + taskRefs: TaskRef[]; + status: OpenCodePromptDeliveryStatus; + responseState: OpenCodeDeliveryResponseState; + attempts: number; + maxAttempts: number; + pendingReason: string; + readAllowed: boolean; + inboxReadCommitted: boolean; + visibleReplyFound: boolean; + hasKnownProgressProof: boolean; + toolCallNames: string[]; + acceptanceUnknown: boolean; + hardFailureKind: OpenCodePromptDeliveryHardFailureKind; +} + +const SIDE_EFFECT_TOOL_NAMES = new Set([ + 'bash', + 'edit', + 'write', + 'patch', + 'apply_patch', + 'multiedit', + 'multi_edit', +]); + +function none(reason: string): OpenCodePromptDeliveryRepairDecision { + return { kind: 'none', retryable: false, controlText: null, reason }; +} + +function control( + input: OpenCodePromptDeliveryRepairInput, + kind: Exclude, + reason: string, + lines: string[] +): OpenCodePromptDeliveryRepairDecision { + const attemptNumber = Math.min(Math.max(input.attempts + 1, 1), input.maxAttempts); + return { + kind, + retryable: true, + reason, + controlText: [ + '', + `Retry attempt ${attemptNumber}/${input.maxAttempts} for inbound app messageId "${input.inboxMessageId}".`, + ...lines, + '', + ].join('\n'), + }; +} + +function normalizeToolName(toolName: string): string { + return toolName + .trim() + .toLowerCase() + .replace(/^mcp__agent[-_]teams__/, '') + .replace(/^agent[-_]teams_/, '') + .replace(/^mcp__agent_teams__/, '') + .replace(/^agent_teams_/, ''); +} + +function normalizedToolNames(input: OpenCodePromptDeliveryRepairInput): Set { + return new Set(input.toolCallNames.map(normalizeToolName).filter(Boolean)); +} + +function hasTool(tools: Set, toolName: string): boolean { + return tools.has(toolName); +} + +function hasTaskTool(tools: Set): boolean { + for (const tool of tools) { + if (tool.startsWith('task_') || tool === 'runtime_task_event') { + return true; + } + } + return false; +} + +function hasSideEffectTool(tools: Set): boolean { + for (const tool of tools) { + if (SIDE_EFFECT_TOOL_NAMES.has(tool)) { + return true; + } + } + return false; +} + +function taskIdList(taskRefs: TaskRef[]): string | null { + const ids = [...new Set(taskRefs.map((taskRef) => taskRef.taskId?.trim()).filter(Boolean))]; + return ids.length > 0 ? ids.map((id) => `"${id}"`).join(', ') : null; +} + +function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): string[] { + const replyRecipient = input.replyRecipient.trim() || 'user'; + return [ + 'The app still has no correlated visible reply proof for this message.', + `Call agent-teams_message_send or mcp__agent-teams__message_send exactly once with teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", and relayOfMessageId="${input.inboxMessageId}".`, + 'Use a concrete answer in text and summary. Do not reply only with acknowledgement.', + 'After the message_send tool succeeds, stop this turn. Do not repeat task/tool work unless the inbound message explicitly asks for new work.', + ]; +} + +function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] { + const taskIds = taskIdList(input.taskRefs); + return [ + 'This is a member-work-sync control message. A plain acknowledgement is not sufficient proof.', + `Call agent-teams_member_work_sync_status or mcp__agent-teams__member_work_sync_status with teamName="${input.teamName}" and memberName="${input.memberName}".`, + 'Then call agent-teams_member_work_sync_report or mcp__agent-teams__member_work_sync_report using the agendaFingerprint/reportToken returned by status.', + taskIds ? `Include taskIds ${taskIds} when reporting if those tasks are still relevant.` : null, + 'Use state "still_working", "blocked", or "caught_up" according to the status result. Do not invent or reuse a raw report token from this retry text.', + ].filter((line): line is string => line !== null); +} + +function progressControlLines(input: OpenCodePromptDeliveryRepairInput): string[] { + const taskIds = taskIdList(input.taskRefs); + return [ + 'The app saw a tool/action response, but no accepted progress proof for this message.', + taskIds + ? `Produce concrete task/progress proof for taskIds ${taskIds}, or send a visible status reply with relayOfMessageId="${input.inboxMessageId}".` + : `Send a concrete visible status reply with relayOfMessageId="${input.inboxMessageId}".`, + 'Do not repeat side-effectful commands, edits, or writes just because this is a retry.', + 'If work is blocked, report the blocker instead of silently ending the turn.', + ]; +} + +function noAssistantControlLines(input: OpenCodePromptDeliveryRepairInput): string[] { + return [ + 'The app saw the prompt but did not observe assistant response proof.', + 'You must not end this turn empty.', + input.messageKind === 'member_work_sync_nudge' + ? 'Follow the member-work-sync status/report instructions for this message.' + : `Send a concrete reply using message_send with relayOfMessageId="${input.inboxMessageId}", or provide a concrete plain-text answer only if message_send is unavailable.`, + ]; +} + +function toolErrorControl(input: OpenCodePromptDeliveryRepairInput) { + const tools = normalizedToolNames(input); + if (hasTool(tools, 'message_send')) { + return control( + input, + 'missing_visible_reply_correlation', + 'message_send_tool_error_without_visible_reply_proof', + messageSendControlLines(input) + ); + } + if (hasTool(tools, 'member_work_sync_report') || hasTool(tools, 'member_work_sync_status')) { + return control( + input, + 'work_sync_report_required', + 'member_work_sync_tool_error_without_report_proof', + workSyncControlLines(input) + ); + } + if (hasSideEffectTool(tools)) { + return control( + input, + 'progress_proof_required', + 'side_effect_tool_error_without_progress_proof', + progressControlLines(input) + ); + } + if (hasTaskTool(tools)) { + return control( + input, + 'progress_proof_required', + 'task_tool_error_without_progress_proof', + progressControlLines(input) + ); + } + return control( + input, + 'progress_proof_required', + 'tool_error_without_required_delivery_proof', + progressControlLines(input) + ); +} + +export function decideOpenCodePromptDeliveryRepair( + input: OpenCodePromptDeliveryRepairInput +): OpenCodePromptDeliveryRepairDecision { + if (input.readAllowed) { + return none('read_commit_allowed'); + } + if (input.inboxReadCommitted) { + return none('inbox_read_already_committed'); + } + if (input.status === 'failed_terminal') { + return none('terminal_record'); + } + if (input.attempts >= input.maxAttempts) { + return none('max_attempts_reached'); + } + if (input.hardFailureKind !== 'none') { + return none(`hard_failure:${input.hardFailureKind}`); + } + if (input.status === 'pending' && input.attempts <= 0 && !input.acceptanceUnknown) { + return none('initial_delivery'); + } + + if (input.acceptanceUnknown) { + return control(input, 'no_assistant_response', 'acceptance_unknown', [ + 'The app could not confirm whether the previous OpenCode prompt was accepted.', + 'Process the inbound message now. If you already completed it, send only the missing proof and do not duplicate side effects.', + input.messageKind === 'member_work_sync_nudge' + ? 'For work-sync, use member_work_sync_status then member_work_sync_report.' + : `For visible replies, use relayOfMessageId="${input.inboxMessageId}".`, + ]); + } + + if (input.messageKind === 'member_work_sync_nudge') { + return control( + input, + 'work_sync_report_required', + input.pendingReason, + workSyncControlLines(input) + ); + } + + if (input.pendingReason === 'plain_text_visible_reply_not_materialized_yet') { + return { + kind: 'app_materialization_pending', + retryable: false, + controlText: null, + reason: input.pendingReason, + }; + } + + if ( + input.pendingReason === 'visible_reply_destination_not_found_yet' || + input.pendingReason === 'visible_reply_missing_relayOfMessageId' || + input.pendingReason === 'visible_reply_still_required' || + (input.responseState === 'responded_visible_message' && !input.visibleReplyFound) + ) { + return control( + input, + 'missing_visible_reply_correlation', + input.pendingReason, + messageSendControlLines(input) + ); + } + + if ( + input.pendingReason === 'visible_reply_ack_only_still_requires_answer' || + input.pendingReason === 'plain_text_ack_only_still_requires_answer' + ) { + return control(input, 'visible_answer_required', input.pendingReason, [ + 'The previous response looked like acknowledgement only, not a concrete answer.', + ...messageSendControlLines(input), + ]); + } + + if (input.responseState === 'tool_error') { + return toolErrorControl(input); + } + + if ( + input.responseState === 'empty_assistant_turn' || + input.responseState === 'prompt_delivered_no_assistant_message' || + input.responseState === 'not_observed' || + input.responseState === 'reconcile_failed' + ) { + return control( + input, + 'no_assistant_response', + input.pendingReason, + noAssistantControlLines(input) + ); + } + + if ( + (input.responseState === 'responded_non_visible_tool' || + input.responseState === 'responded_tool_call') && + !input.hasKnownProgressProof + ) { + return control( + input, + 'progress_proof_required', + input.pendingReason, + progressControlLines(input) + ); + } + + return none(input.pendingReason || 'no_repair_needed'); +}