From bc571f5fc7d6594a0122551e5e55ac23e13d21a2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 14 May 2026 02:09:18 +0300 Subject: [PATCH] fix(member-work-sync): guard proof-missing recovery dispatch --- .../MemberWorkSyncNudgeDispatcher.ts | 42 ++++++ .../core/application/ports.ts | 14 ++ .../createMemberWorkSyncFeature.ts | 22 ++- src/main/index.ts | 42 ++++++ .../main/createMemberWorkSyncFeature.test.ts | 140 ++++++++++++++++++ .../team/RuntimeDiagnosticClassifier.test.ts | 49 ++++++ ...nCodeDeliveryAdvisory.fixture-e2e.test.tsx | 43 +++++- 7 files changed, 350 insertions(+), 2 deletions(-) diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index 6536be08..98976819 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -61,6 +61,17 @@ function isReviewPickupOutboxItem(item: MemberWorkSyncOutboxItem): boolean { return item.payload.workSyncIntent === 'review_pickup'; } +function getProofMissingRecoveryOriginalMessageId(item: MemberWorkSyncOutboxItem): string | null { + const prefix = 'proof-missing:'; + const intentKey = item.payload.workSyncIntentKey?.trim(); + if (!intentKey?.startsWith(prefix)) { + return null; + } + + const originalMessageId = intentKey.slice(prefix.length).trim(); + return originalMessageId.length > 0 ? originalMessageId : null; +} + function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] { return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])] .filter((id) => id.length > 0) @@ -463,6 +474,11 @@ export class MemberWorkSyncNudgeDispatcher { } } + const proofMissingRecovery = await this.revalidateProofMissingRecovery(item, nowIso); + if (!proofMissingRecovery.ok) { + return proofMissingRecovery; + } + const recentDelivered = await this.deps.outboxStore?.countRecentDelivered({ teamName: item.teamName, memberName: item.memberName, @@ -513,6 +529,32 @@ export class MemberWorkSyncNudgeDispatcher { return { ok: true, ...(providerId ? { providerId } : {}) }; } + private async revalidateProofMissingRecovery( + item: MemberWorkSyncOutboxItem, + nowIso: string + ): Promise< + { ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } + > { + const originalMessageId = getProofMissingRecoveryOriginalMessageId(item); + if (!originalMessageId) { + return { ok: true }; + } + + const guard = this.deps.proofMissingRecoveryGuard; + if (!guard) { + return { ok: true }; + } + + return guard.shouldDispatch({ + teamName: item.teamName, + memberName: item.memberName, + intentKey: item.payload.workSyncIntentKey ?? '', + originalMessageId, + taskIds: item.payload.taskRefs.map((taskRef) => taskRef.taskId), + nowIso, + }); + } + private async scheduleDeliveryWake( item: MemberWorkSyncOutboxItem, messageId: string, diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index e04bf83d..64e2c4fa 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -210,6 +210,19 @@ export interface MemberWorkSyncBusySignalPort { }): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>; } +export interface MemberWorkSyncProofMissingRecoveryGuardPort { + shouldDispatch(input: { + teamName: string; + memberName: string; + intentKey: string; + originalMessageId: string; + taskIds: string[]; + nowIso: string; + }): Promise< + { ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } + >; +} + export interface MemberWorkSyncNudgeDeliveryWakePort { schedule(input: { teamName: string; @@ -281,6 +294,7 @@ export interface MemberWorkSyncUseCaseDeps { inboxNudge?: MemberWorkSyncInboxNudgePort; watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort; busySignal?: MemberWorkSyncBusySignalPort; + proofMissingRecoveryGuard?: MemberWorkSyncProofMissingRecoveryGuardPort; nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort; reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort; reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort; diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index ad994707..2d44f586 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -51,6 +51,7 @@ import type { MemberWorkSyncBusySignalPort, MemberWorkSyncLoggerPort, MemberWorkSyncNudgeDeliveryWakePort, + MemberWorkSyncProofMissingRecoveryGuardPort, MemberWorkSyncReviewPickupDeliveryPort, MemberWorkSyncReviewPickupEscalationPort, } from '../../core/application'; @@ -173,6 +174,7 @@ export function createMemberWorkSyncFeature(deps: { queueQuietWindowMs?: number; runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort; extraBusySignals?: MemberWorkSyncBusySignalPort[]; + proofMissingRecoveryGuard?: MemberWorkSyncProofMissingRecoveryGuardPort; nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort; reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort; reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort; @@ -238,6 +240,9 @@ export function createMemberWorkSyncFeature(deps: { inboxNudge, watchdogCooldown, busySignal, + ...(deps.proofMissingRecoveryGuard + ? { proofMissingRecoveryGuard: deps.proofMissingRecoveryGuard } + : {}), ...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}), ...(deps.reviewPickupDelivery ? { reviewPickupDelivery: deps.reviewPickupDelivery } : {}), ...(deps.reviewPickupEscalation ? { reviewPickupEscalation: deps.reviewPickupEscalation } : {}), @@ -382,6 +387,22 @@ export function createMemberWorkSyncFeature(deps: { return { scheduled: false, reason: 'invalid' }; } + const taskRefs = normalizeRecoveryTaskRefs(request.taskRefs); + if (taskRefs.length === 0) { + await auditJournal.append({ + timestamp: clock.now().toISOString(), + teamName, + memberName, + event: 'proof_missing_recovery_suppressed', + source: 'proof_missing_recovery_scheduler', + reason: 'missing_task_refs', + metadata: { + originalMessageId, + }, + }); + return { scheduled: false, reason: 'invalid' }; + } + const intentKey = buildProofMissingRecoveryIntentKey(originalMessageId); const sinceIso = new Date( clock.now().getTime() - PROOF_MISSING_RECOVERY_RECENT_WINDOW_MS @@ -414,7 +435,6 @@ export function createMemberWorkSyncFeature(deps: { }; } - const taskRefs = normalizeRecoveryTaskRefs(request.taskRefs); await auditJournal.append({ timestamp: clock.now().toISOString(), teamName, diff --git a/src/main/index.ts b/src/main/index.ts index 4e5569fd..3e74e16b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1682,6 +1682,48 @@ async function initializeServices(): Promise { isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input), }, ], + proofMissingRecoveryGuard: { + shouldDispatch: async (input) => { + const status = await teamProvisioningService.getOpenCodeRuntimeDeliveryStatus( + input.teamName, + input.originalMessageId + ); + if (!status) { + return { + ok: false, + reason: 'proof_missing_recovery_record_missing', + retryable: false, + }; + } + + const impact = status.userVisibleImpact; + if (impact?.reasonCode === 'protocol_proof_missing') { + if (impact.state === 'checking') { + return { + ok: false, + reason: 'proof_missing_recovery_still_in_grace', + retryable: true, + ...(impact.nextReviewAt ? { nextAttemptAt: impact.nextReviewAt } : {}), + }; + } + return { ok: true }; + } + + if (status.responsePending) { + return { + ok: false, + reason: 'proof_missing_recovery_delivery_still_pending', + retryable: true, + }; + } + + return { + ok: false, + reason: 'proof_missing_recovery_suppressed', + retryable: false, + }; + }, + }, nudgeDeliveryWake: { schedule: async (input) => { if (input.providerId === 'opencode') { diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 4a209371..7ce261db 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -413,6 +413,7 @@ describe('createMemberWorkSyncFeature composition', () => { teamName, memberName, originalMessageId: 'message-1', + taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }], }) ).resolves.toMatchObject({ scheduled: false, @@ -425,6 +426,47 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('does not schedule broad proof-missing recovery without task refs', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName }], + })), + } as never, + taskReader: { getTasks: vi.fn(async () => []) } as never, + kanbanManager: { + getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })), + } as never, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + }); + + try { + await expect( + feature.scheduleProofMissingRecovery({ + teamName, + memberName, + originalMessageId: 'message-1', + }) + ).resolves.toMatchObject({ + scheduled: false, + reason: 'invalid', + }); + expect(feature.getQueueDiagnostics()).toMatchObject({ queued: 0 }); + await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual( + [] + ); + } finally { + await feature.dispose(); + } + }); + it('dispatches a due nudge through the real outbox and inbox by default', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); @@ -502,6 +544,104 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('suppresses queued proof-missing recovery when the original delivery is no longer proof-missing', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const proofMissingRecoveryGuard = { + shouldDispatch: vi.fn(async () => ({ + ok: false as const, + reason: 'proof_missing_recovery_suppressed', + retryable: false, + })), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + proofMissingRecoveryGuard, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + const status = await feature.refreshStatus({ teamName, memberName }); + const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); + await expect( + store.ensurePending({ + id: 'member-work-sync:team-a:bob:proof-missing:message-1', + teamName, + memberName, + agendaFingerprint: status.agenda.fingerprint, + payloadHash: 'payload-hash', + payload: { + from: 'system', + to: memberName, + messageKind: 'member_work_sync_nudge', + source: 'member-work-sync', + actionMode: 'do', + workSyncIntent: 'agenda_sync', + workSyncIntentKey: 'proof-missing:message-1', + text: 'Recover proof', + taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }], + }, + nowIso: status.evaluatedAt, + }) + ).resolves.toMatchObject({ + ok: true, + outcome: 'created', + }); + + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 0, + superseded: 1, + retryable: 0, + terminal: 0, + }); + expect(proofMissingRecoveryGuard.shouldDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + teamName, + memberName, + intentKey: 'proof-missing:message-1', + originalMessageId: 'message-1', + taskIds: ['task-1'], + }) + ); + await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual( + [] + ); + } finally { + await feature.dispose(); + } + }); + it('does not deliver pending nudges until the team is ready for nudge dispatch', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); diff --git a/test/main/services/team/RuntimeDiagnosticClassifier.test.ts b/test/main/services/team/RuntimeDiagnosticClassifier.test.ts index d106abb7..4635e891 100644 --- a/test/main/services/team/RuntimeDiagnosticClassifier.test.ts +++ b/test/main/services/team/RuntimeDiagnosticClassifier.test.ts @@ -81,4 +81,53 @@ describe('RuntimeDiagnosticClassifier', () => { actionRequired: false, }); }); + + it('does not classify message_send Not connected as protocol proof missing', () => { + expect( + classifyRuntimeDiagnostic( + 'agent-teams_message_send returned Not connected while sending a visible reply' + ) + ).toMatchObject({ + reasonCode: 'backend_error', + actionRequired: false, + }); + }); + + it('keeps explicit proof-missing diagnostics narrow', () => { + expect( + classifyRuntimeDiagnostic( + 'OpenCode used tools, but did not create a visible reply or task progress proof.' + ) + ).toMatchObject({ + reasonCode: 'protocol_proof_missing', + generic: true, + }); + }); + + it('keeps quota and auth diagnostics above proof-missing substrings in the same message', () => { + expect( + classifyRuntimeDiagnostic( + 'Insufficient credits: OpenCode used tools, but did not create a visible reply or task progress proof.' + ) + ).toMatchObject({ + reasonCode: 'quota_exhausted', + actionRequired: true, + }); + + expect( + classifyRuntimeDiagnostic( + 'authentication_failed: visible_reply_missing_task_refs because API key is invalid' + ) + ).toMatchObject({ + reasonCode: 'auth_error', + actionRequired: true, + }); + }); + + it('keeps OpenCode bridge command timeout as backend state despite timeout tokens', () => { + expect(classifyRuntimeDiagnostic('OpenCode bridge command timed out')).toMatchObject({ + reasonCode: 'backend_error', + generic: true, + }); + }); }); diff --git a/test/renderer/components/team/members/MemberCardOpenCodeDeliveryAdvisory.fixture-e2e.test.tsx b/test/renderer/components/team/members/MemberCardOpenCodeDeliveryAdvisory.fixture-e2e.test.tsx index caceabfa..30cf3f24 100644 --- a/test/renderer/components/team/members/MemberCardOpenCodeDeliveryAdvisory.fixture-e2e.test.tsx +++ b/test/renderer/components/team/members/MemberCardOpenCodeDeliveryAdvisory.fixture-e2e.test.tsx @@ -206,6 +206,41 @@ describe('MemberCard OpenCode delivery advisory fixture e2e', () => { 'System notice: OpenCode teammate @jack hit a runtime delivery error while handling #task-1.' ); }); + + it('schedules targeted work-sync recovery for stale protocol proof-missing advisory', async () => { + const record = makeDeliveryRecord({ + failedAt: OLD_FAILURE_ISO, + updatedAt: OLD_FAILURE_ISO, + lastObservedAt: OLD_FAILURE_ISO, + respondedAt: OLD_FAILURE_ISO, + responseState: 'responded_non_visible_tool', + lastReason: 'non_visible_tool_without_task_progress', + diagnostics: [ + 'OpenCode used tools, but did not create a visible reply or task progress proof.', + ], + }); + await writeDeliveryFixture(record); + const scheduleProofMissingRecovery = vi.fn(async () => ({ + scheduled: true, + reason: 'scheduled' as const, + intentKey: 'proof-missing:msg-empty-turn', + })); + + const sideEffects = await runUserFacingSideEffects(record, scheduleProofMissingRecovery); + + expect(scheduleProofMissingRecovery).toHaveBeenCalledTimes(1); + expect(scheduleProofMissingRecovery).toHaveBeenCalledWith({ + teamName: TEAM_NAME, + memberName: MEMBER_NAME, + originalMessageId: record.inboxMessageId, + taskRefs: record.taskRefs, + reason: + 'OpenCode used tools, but did not create a visible reply or task progress proof.', + }); + expect(sideEffects.addTeamNotification).not.toHaveBeenCalled(); + expect(sideEffects.sendMessageToRun).not.toHaveBeenCalled(); + expect(sideEffects.invalidations).toEqual([{ teamName: TEAM_NAME, memberName: MEMBER_NAME }]); + }); }); async function readMemberAdvisory(): Promise { @@ -252,7 +287,10 @@ async function renderMemberCardText( } async function runUserFacingSideEffects( - record: OpenCodePromptDeliveryLedgerRecord + record: OpenCodePromptDeliveryLedgerRecord, + scheduleProofMissingRecovery?: Parameters< + TeamProvisioningService['setMemberWorkSyncProofMissingRecoveryScheduler'] + >[0] ): Promise { const addTeamNotification = vi.fn(() => Promise.resolve(undefined)); NotificationManager.setInstance({ addTeamNotification } as never); @@ -269,6 +307,9 @@ async function runUserFacingSideEffects( service.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => { invalidations.push({ teamName, memberName }); }); + if (scheduleProofMissingRecovery) { + service.setMemberWorkSyncProofMissingRecoveryScheduler(scheduleProofMissingRecovery); + } access.sendMessageToRun = sendMessageToRun; access.aliveRunByTeam.set(TEAM_NAME, 'lead-run-1'); access.runs.set('lead-run-1', {