From d048113c1de9f7d9954baf7cc16050f8a61cd949 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 14 May 2026 01:54:16 +0300 Subject: [PATCH] fix(member-work-sync): avoid self-blocking proof recovery --- .../MemberWorkSyncNudgeDispatcher.ts | 1 + .../core/application/ports.ts | 1 + .../services/team/TeamProvisioningService.ts | 23 +++++- .../team/TeamProvisioningServiceRelay.test.ts | 75 +++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index 72ac80e6..6536be08 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -485,6 +485,7 @@ export class MemberWorkSyncNudgeDispatcher { memberName: item.memberName, nowIso, workSyncIntent: item.payload.workSyncIntent, + workSyncIntentKey: item.payload.workSyncIntentKey, taskRefs: item.payload.taskRefs, }); if (busy?.busy) { diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 7d45b0cd..e04bf83d 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -205,6 +205,7 @@ export interface MemberWorkSyncBusySignalPort { memberName: string; nowIso: string; workSyncIntent?: MemberWorkSyncOutboxItem['payload']['workSyncIntent']; + workSyncIntentKey?: MemberWorkSyncOutboxItem['payload']['workSyncIntentKey']; taskRefs?: MemberWorkSyncOutboxItem['payload']['taskRefs']; }): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2b582c67..d3ad9445 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -12753,6 +12753,7 @@ export class TeamProvisioningService { memberName: string; nowIso: string; workSyncIntent?: 'agenda_sync' | 'review_pickup'; + workSyncIntentKey?: string; taskRefs?: TaskRef[]; }): Promise<{ busy: boolean; @@ -12785,7 +12786,9 @@ export class TeamProvisioningService { (message) => message.messageKind !== 'member_work_sync_nudge' ); const blockingForegroundMessages = foregroundMessages.filter( - (message) => !this.isCurrentReviewPickupRequestForegroundMessage(message, input) + (message) => + !this.isCurrentReviewPickupRequestForegroundMessage(message, input) && + !this.isCurrentProofMissingRecoveryForegroundMessage(message, input) ); const unreadForeground = blockingForegroundMessages.find( (message) => @@ -22214,6 +22217,24 @@ export class TeamProvisioningService { ); } + private isCurrentProofMissingRecoveryForegroundMessage( + message: InboxMessage, + input: { workSyncIntent?: 'agenda_sync' | 'review_pickup'; workSyncIntentKey?: string } + ): boolean { + if (input.workSyncIntent !== 'agenda_sync') { + return false; + } + + const prefix = 'proof-missing:'; + const intentKey = input.workSyncIntentKey?.trim(); + if (!intentKey?.startsWith(prefix)) { + return false; + } + + const originalMessageId = intentKey.slice(prefix.length).trim(); + return this.hasStableMessageId(message) && message.messageId.trim() === originalMessageId; + } + private openCodeReviewPickupRequestTextMentionsTask(input: { summary: string; text: string; diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index efeb8d0e..94607291 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -3127,6 +3127,81 @@ Messages: }); }); + it('does not let proof-missing recovery get blocked by its original unread message', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'user', + to: 'jack', + text: 'Please check the current issue.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'foreground-message-1', + messageKind: 'direct', + }, + ]) + ); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + getActiveForMember: vi.fn(async () => null), + }); + + const sameMessageRecoveryBusy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:10.000Z', + workSyncIntent: 'agenda_sync', + workSyncIntentKey: 'proof-missing:foreground-message-1', + }); + + expect(sameMessageRecoveryBusy).toEqual({ busy: false }); + + const unrelatedRecoveryBusy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:10.000Z', + workSyncIntent: 'agenda_sync', + workSyncIntentKey: 'proof-missing:another-message', + }); + + expect(unrelatedRecoveryBusy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'foreground-message-1', + }); + }); + it('does not treat the current unread OpenCode review request as busy for review-pickup checks', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team';