diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index 5c16676e..524c243d 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -75,11 +75,26 @@ function normalizeMessageKind(messageKind) { return messageKind === 'default' || messageKind === 'slash_command' || messageKind === 'slash_command_result' || - messageKind === 'task_comment_notification' + messageKind === 'task_comment_notification' || + messageKind === 'member_work_sync_nudge' ? messageKind : undefined; } +function normalizeWorkSyncIntent(workSyncIntent) { + return workSyncIntent === 'agenda_sync' || workSyncIntent === 'review_pickup' + ? workSyncIntent + : undefined; +} + +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return undefined; + } + const items = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))]; + return items.length > 0 ? items : undefined; +} + function normalizeSlashCommand(slashCommand) { if (!slashCommand || typeof slashCommand !== 'object') { return undefined; @@ -123,6 +138,8 @@ function buildMessage(flags, defaults) { const attachments = normalizeAttachments(flags.attachments); const taskRefs = normalizeTaskRefs(flags.taskRefs); const messageKind = normalizeMessageKind(flags.messageKind); + const workSyncIntent = normalizeWorkSyncIntent(flags.workSyncIntent); + const workSyncReviewRequestEventIds = normalizeStringList(flags.workSyncReviewRequestEventIds); const slashCommand = normalizeSlashCommand(flags.slashCommand); const commandOutput = normalizeCommandOutput(flags.commandOutput); @@ -173,6 +190,11 @@ function buildMessage(flags, defaults) { } : {}), ...(messageKind ? { messageKind } : {}), + ...(workSyncIntent ? { workSyncIntent } : {}), + ...(typeof flags.workSyncIntentKey === 'string' && flags.workSyncIntentKey.trim() + ? { workSyncIntentKey: flags.workSyncIntentKey.trim() } + : {}), + ...(workSyncReviewRequestEventIds ? { workSyncReviewRequestEventIds } : {}), ...(slashCommand ? { slashCommand } : {}), ...(commandOutput ? { commandOutput } : {}), ...(attachments ? { attachments } : {}), diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 598beb52..bf736cb2 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -1130,7 +1130,10 @@ describe('agent-teams-controller API', () => { commentId: 'comment-123', relayOfMessageId: 'm-original-1', source: 'system_notification', - messageKind: 'task_comment_notification', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'review_pickup', + workSyncIntentKey: 'review-pickup:evt-1', + workSyncReviewRequestEventIds: ['evt-1'], leadSessionId: 'session-42', attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }], }); @@ -1142,7 +1145,10 @@ describe('agent-teams-controller API', () => { const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(rows).toHaveLength(1); expect(rows[0].source).toBe('system_notification'); - expect(rows[0].messageKind).toBe('task_comment_notification'); + expect(rows[0].messageKind).toBe('member_work_sync_nudge'); + expect(rows[0].workSyncIntent).toBe('review_pickup'); + expect(rows[0].workSyncIntentKey).toBe('review-pickup:evt-1'); + expect(rows[0].workSyncReviewRequestEventIds).toEqual(['evt-1']); expect(rows[0].commentId).toBe('comment-123'); expect(rows[0].relayOfMessageId).toBe('m-original-1'); expect(rows[0].leadSessionId).toBe('session-42'); diff --git a/src/features/agent-attachments/main/index.ts b/src/features/agent-attachments/main/index.ts index 5a5dd085..c2633584 100644 --- a/src/features/agent-attachments/main/index.ts +++ b/src/features/agent-attachments/main/index.ts @@ -1,3 +1,4 @@ +export { AgentAttachmentError } from '../core/domain'; export * from './infrastructure/attachmentArtifactStore'; export * from './providers/claudeAttachmentAdapter'; export * from './providers/codexNativeAttachmentAdapter'; diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index f7ddc2c0..3b76879f 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -22,6 +22,15 @@ export type MemberWorkSyncActionableWorkPriority = export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; +export type MemberWorkSyncReviewObligation = 'review_pickup_required' | 'review_in_progress'; + +export type MemberWorkSyncNudgeIntent = 'agenda_sync' | 'review_pickup'; + +export type MemberWorkSyncReviewPickupDeliveryState = + | 'inbox_persisted' + | 'prompt_accepted' + | 'response_proven'; + export interface MemberWorkSyncActionableWorkItem { taskId: string; displayId?: string; @@ -35,6 +44,15 @@ export interface MemberWorkSyncActionableWorkItem { owner?: string; reviewer?: string; reviewState?: string; + reviewCycleId?: string; + reviewRequestEventId?: string; + reviewRequestedAt?: string; + reviewStartedEventId?: string; + reviewStartedAt?: string; + reviewStartedBy?: string; + reviewObligation?: MemberWorkSyncReviewObligation; + canBypassPhase2?: boolean; + reviewDiagnostics?: string[]; needsClarification?: 'lead' | 'user'; blockerTaskIds?: string[]; blockedByTaskIds?: string[]; @@ -220,6 +238,9 @@ export interface MemberWorkSyncNudgePayload { messageKind: 'member_work_sync_nudge'; source: 'member-work-sync'; actionMode: 'do'; + workSyncIntent: MemberWorkSyncNudgeIntent; + workSyncIntentKey?: string; + workSyncReviewRequestEventIds?: string[]; text: string; taskRefs: { taskId: string; @@ -240,6 +261,8 @@ export interface MemberWorkSyncOutboxItem { claimedBy?: string; claimedAt?: string; deliveredMessageId?: string; + deliveryState?: MemberWorkSyncReviewPickupDeliveryState; + deliveryDiagnostics?: string[]; lastError?: string; nextAttemptAt?: string; createdAt: string; @@ -279,6 +302,8 @@ export interface MemberWorkSyncOutboxMarkDeliveredInput { id: string; attemptGeneration: number; deliveredMessageId: string; + deliveryState?: MemberWorkSyncReviewPickupDeliveryState; + deliveryDiagnostics?: string[]; nowIso: string; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index 7f14c441..a24ce2b0 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -3,6 +3,7 @@ import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../cont export type MemberWorkSyncNudgeActivationReason = | 'shadow_ready' | 'opencode_targeted_shadow_collecting' + | 'review_pickup_required' | 'status_not_nudgeable' | 'blocking_metrics' | 'phase2_not_ready'; @@ -27,10 +28,35 @@ function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean { status.providerId === 'opencode' && status.state === 'needs_sync' && status.agenda.items.length > 0 && + !isReviewPickupAgenda(status) && status.shadow?.wouldNudge === true ); } +function isStrictReviewPickupItem(item: MemberWorkSyncStatus['agenda']['items'][number]): boolean { + return ( + item.kind === 'review' && + item.evidence.reviewObligation === 'review_pickup_required' && + item.evidence.canBypassPhase2 === true && + typeof item.evidence.reviewRequestEventId === 'string' && + item.evidence.reviewRequestEventId.length > 0 && + (item.evidence.reviewDiagnostics?.length ?? 0) === 0 + ); +} + +function isReviewPickupAgenda(status: MemberWorkSyncStatus): boolean { + return status.agenda.items.length > 0 && status.agenda.items.every(isStrictReviewPickupItem); +} + +function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean { + return ( + status.state === 'needs_sync' && + status.shadow?.wouldNudge === true && + status.agenda.items.length > 0 && + status.agenda.items.every(isStrictReviewPickupItem) + ); +} + export function decideMemberWorkSyncNudgeActivation(input: { status: MemberWorkSyncStatus; metrics: MemberWorkSyncTeamMetrics; @@ -43,6 +69,10 @@ export function decideMemberWorkSyncNudgeActivation(input: { return { active: false, reason: 'blocking_metrics' }; } + if (isReviewPickupRequiredCandidate(input.status)) { + return { active: true, reason: 'review_pickup_required' }; + } + if (input.metrics.phase2Readiness.state === 'shadow_ready') { return { active: true, reason: 'shadow_ready' }; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index 208f00ec..789a6840 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -4,7 +4,11 @@ import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncA import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy'; import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler'; -import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts'; +import type { + MemberWorkSyncAgenda, + MemberWorkSyncOutboxItem, + MemberWorkSyncStatus, +} from '../../contracts'; import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports'; const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; @@ -53,6 +57,42 @@ function nextRetryAt(item: MemberWorkSyncOutboxItem, nowIso: string): string { return addMinutes(nowIso, cappedMinutes + stableJitterMinutes(item.id, item.attemptGeneration)); } +function isReviewPickupOutboxItem(item: MemberWorkSyncOutboxItem): boolean { + return item.payload.workSyncIntent === 'review_pickup'; +} + +function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] { + return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])] + .filter((id) => id.length > 0) + .sort(); +} + +function getAgendaReviewPickupRequestEventIds(agenda: MemberWorkSyncAgenda): string[] { + return [ + ...new Set( + agenda.items + .filter( + (item) => + item.kind === 'review' && + item.evidence.reviewObligation === 'review_pickup_required' && + item.evidence.canBypassPhase2 === true && + (item.evidence.reviewDiagnostics?.length ?? 0) === 0 + ) + .map((item) => item.evidence.reviewRequestEventId) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + ), + ].sort(); +} + +function reviewPickupRequestIdsStillMatch( + item: MemberWorkSyncOutboxItem, + agenda: MemberWorkSyncAgenda +): boolean { + const payloadIds = getPayloadReviewRequestEventIds(item); + const agendaIds = getAgendaReviewPickupRequestEventIds(agenda); + return payloadIds.length > 0 && payloadIds.every((id) => agendaIds.includes(id)); +} + export class MemberWorkSyncNudgeDispatcher { constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} @@ -114,6 +154,10 @@ export class MemberWorkSyncNudgeDispatcher { ); return 'retryable'; } + if (revalidation.reason.startsWith('review_pickup_delivery_unavailable:')) { + await this.markReviewPickupDeliveryUnavailable(item, nowIso, revalidation.reason); + return 'superseded'; + } await outbox.markSuperseded({ teamName: item.teamName, id: item.id, @@ -145,6 +189,15 @@ export class MemberWorkSyncNudgeDispatcher { await this.appendDispatchAudit(item, 'nudge_skipped', 'inbox_payload_conflict'); return 'terminal'; } + if (isReviewPickupOutboxItem(item)) { + return await this.deliverReviewPickupNudge( + item, + inserted.messageId, + inserted.inserted, + revalidation.providerId, + nowIso + ); + } await outbox.markDelivered({ teamName: item.teamName, id: item.id, @@ -175,6 +228,126 @@ export class MemberWorkSyncNudgeDispatcher { } } + private async deliverReviewPickupNudge( + item: MemberWorkSyncOutboxItem, + messageId: string, + inserted: boolean, + providerId: MemberWorkSyncStatus['providerId'] | undefined, + nowIso: string + ): Promise> { + const outbox = this.deps.outboxStore; + const delivery = this.deps.reviewPickupDelivery; + if (!outbox || !delivery) { + await this.markReviewPickupDeliveryUnavailable( + item, + nowIso, + 'review_pickup_delivery_port_unavailable' + ); + return 'superseded'; + } + + const outcome = await delivery.deliver({ + teamName: item.teamName, + memberName: item.memberName, + messageId, + ...(providerId ? { providerId } : {}), + payload: item.payload, + inserted, + nowIso, + }); + + if (outcome.ok) { + await outbox.markDelivered({ + teamName: item.teamName, + id: item.id, + attemptGeneration: item.attemptGeneration, + deliveredMessageId: outcome.messageId, + deliveryState: outcome.state, + deliveryDiagnostics: outcome.diagnostics, + nowIso, + }); + await this.appendDispatchAudit(item, 'review_pickup_member_nudge_delivered', outcome.state); + await this.appendDispatchAudit(item, 'nudge_delivered', `review_pickup:${outcome.state}`); + return 'delivered'; + } + + if (outcome.reason === 'retryable_failure') { + await outbox.markFailed({ + teamName: item.teamName, + id: item.id, + attemptGeneration: item.attemptGeneration, + error: outcome.message, + retryable: true, + nowIso, + nextAttemptAt: outcome.retryAfterIso ?? nextRetryAt(item, nowIso), + }); + await this.appendDispatchAudit(item, 'review_pickup_wake_failed_retryable', outcome.message); + return 'retryable'; + } + + if (outcome.reason === 'capability_absent') { + await this.markReviewPickupDeliveryUnavailable(item, nowIso, outcome.message); + return 'superseded'; + } + + await outbox.markFailed({ + teamName: item.teamName, + id: item.id, + attemptGeneration: item.attemptGeneration, + error: outcome.message, + retryable: false, + nowIso, + }); + await this.appendDispatchAudit(item, 'nudge_skipped', outcome.message); + return 'terminal'; + } + + private async markReviewPickupDeliveryUnavailable( + item: MemberWorkSyncOutboxItem, + nowIso: string, + reason: string + ): Promise { + await this.deps.outboxStore?.markSuperseded({ + teamName: item.teamName, + id: item.id, + reason, + nowIso, + }); + await this.appendDispatchAudit(item, 'review_pickup_delivery_unavailable', reason); + await this.appendDispatchAudit(item, 'review_pickup_escalated', reason); + await this.notifyReviewPickupEscalation(item, nowIso, reason); + } + + private async notifyReviewPickupEscalation( + item: MemberWorkSyncOutboxItem, + nowIso: string, + reason: string + ): Promise { + const escalation = this.deps.reviewPickupEscalation; + if (!escalation) { + return; + } + + try { + await escalation.escalate({ + teamName: item.teamName, + memberName: item.memberName, + reason, + nowIso, + agendaFingerprint: item.agendaFingerprint, + reviewRequestEventIds: getPayloadReviewRequestEventIds(item), + taskRefs: item.payload.taskRefs, + }); + } catch (error) { + this.deps.logger?.warn('member work sync review pickup escalation failed', { + teamName: item.teamName, + memberName: item.memberName, + reason, + error: String(error), + }); + } + } + private async appendDispatchAudit( item: MemberWorkSyncOutboxItem, event: MemberWorkSyncAuditEventName, @@ -248,11 +421,10 @@ export class MemberWorkSyncNudgeDispatcher { diagnostics: [...agenda.diagnostics, ...decision.diagnostics], ...(providerId ? { providerId } : {}), }; - if ( - decision.state !== 'needs_sync' || - agenda.items.length === 0 || - agenda.fingerprint !== item.agendaFingerprint - ) { + const agendaStillMatches = + agenda.fingerprint === item.agendaFingerprint || + (isReviewPickupOutboxItem(item) && reviewPickupRequestIdsStillMatch(item, agenda)); + if (decision.state !== 'needs_sync' || agenda.items.length === 0 || !agendaStillMatches) { return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false }; } @@ -265,7 +437,30 @@ export class MemberWorkSyncNudgeDispatcher { metrics, }); if (!activation.active) { - return { ok: false, reason: 'phase2_not_ready', retryable: true }; + const reason = + activation.reason === 'blocking_metrics' + ? 'blocking_metrics' + : activation.reason === 'status_not_nudgeable' + ? 'status_not_nudgeable' + : 'phase2_not_ready'; + return { ok: false, reason, retryable: true }; + } + + if (isReviewPickupOutboxItem(item)) { + const capability = await this.deps.reviewPickupDelivery?.canDeliver({ + teamName: item.teamName, + memberName: item.memberName, + providerId, + }); + if (!capability?.ok) { + return { + ok: false, + reason: `review_pickup_delivery_unavailable:${ + capability?.reason ?? 'delivery_port_unavailable' + }`, + retryable: false, + }; + } } const recentDelivered = await this.deps.outboxStore?.countRecentDelivered({ diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts index 23129a6e..8eb9171d 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts @@ -6,13 +6,44 @@ import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActiva import type { MemberWorkSyncStatus } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; +function getReviewRequestEventIds(status: MemberWorkSyncStatus): string[] { + return [ + ...new Set( + status.agenda.items + .map((item) => item.evidence.reviewRequestEventId?.trim()) + .filter((id): id is string => Boolean(id)) + ), + ].sort(); +} + +function filterReviewPickupStatusByRequestIds( + status: MemberWorkSyncStatus, + reviewRequestEventIds: string[] +): MemberWorkSyncStatus { + const allowed = new Set(reviewRequestEventIds); + return { + ...status, + agenda: { + ...status.agenda, + items: status.agenda.items.filter((item) => { + const eventId = item.evidence.reviewRequestEventId?.trim(); + return eventId ? allowed.has(eventId) : false; + }), + }, + }; +} + export interface MemberWorkSyncNudgeOutboxPlanResult { planned: boolean; code: | 'outbox_unavailable' | 'metrics_unavailable' | 'status_not_nudgeable' + | 'blocking_metrics' | 'phase2_not_ready' + | 'review_pickup_delivery_unavailable' + | 'review_pickup_already_delivered_still_stuck' + | 'review_pickup_delivery_failed_still_stuck' | 'created' | 'existing' | 'payload_conflict'; @@ -29,7 +60,7 @@ export class MemberWorkSyncNudgeOutboxPlanner { return { planned: false, code: 'metrics_unavailable' }; } - const input = buildMemberWorkSyncOutboxEnsureInput({ + let input = buildMemberWorkSyncOutboxEnsureInput({ status, hash: this.deps.hash, nowIso: status.evaluatedAt, @@ -41,12 +72,76 @@ export class MemberWorkSyncNudgeOutboxPlanner { const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName); 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' }; + const code = + activation.reason === 'blocking_metrics' + ? 'blocking_metrics' + : activation.reason === 'status_not_nudgeable' + ? 'status_not_nudgeable' + : 'phase2_not_ready'; + await this.appendPlanAudit(status, { planned: false, code }); + return { planned: false, code }; + } + + if (input.payload.workSyncIntent === 'review_pickup') { + const capability = await this.deps.reviewPickupDelivery?.canDeliver({ + teamName: status.teamName, + memberName: status.memberName, + providerId: status.providerId, + }); + if (!capability?.ok) { + const diagnostics = [ + capability?.reason ?? 'review_pickup_delivery_port_unavailable', + ...(capability?.diagnostics ?? []), + ]; + await this.appendReviewPickupDeliveryUnavailableAudit(status, diagnostics); + const result = { + planned: false, + code: 'review_pickup_delivery_unavailable', + } as const; + await this.appendPlanAudit(status, result); + return result; + } + + const requestedEventIds = input.payload.workSyncReviewRequestEventIds ?? []; + const deliveredEventIds = + (await this.deps.outboxStore.findDeliveredReviewPickupRequestEventIds?.({ + teamName: status.teamName, + memberName: status.memberName, + reviewRequestEventIds: requestedEventIds, + })) ?? []; + if (deliveredEventIds.length > 0) { + const delivered = new Set(deliveredEventIds); + const undeliveredEventIds = requestedEventIds.filter((eventId) => !delivered.has(eventId)); + if (undeliveredEventIds.length === 0) { + const code = 'review_pickup_already_delivered_still_stuck' as const; + await this.appendReviewPickupEscalationAudit(status, code); + await this.appendPlanAudit(status, { planned: false, code }); + return { planned: false, code }; + } + + const filteredStatus = filterReviewPickupStatusByRequestIds(status, undeliveredEventIds); + const filteredInput = buildMemberWorkSyncOutboxEnsureInput({ + status: filteredStatus, + hash: this.deps.hash, + nowIso: status.evaluatedAt, + }); + if (!filteredInput) { + const code = 'status_not_nudgeable' as const; + await this.appendPlanAudit(status, { planned: false, code }); + return { planned: false, code }; + } + input = filteredInput; + } } const result = await this.deps.outboxStore.ensurePending(input); if (!result.ok) { + if (input.payload.workSyncIntent === 'review_pickup' && result.item.status === 'delivered') { + const code = 'review_pickup_already_delivered_still_stuck' as const; + await this.appendReviewPickupEscalationAudit(status, code); + await this.appendPlanAudit(status, { planned: false, code }); + return { planned: false, code }; + } this.deps.logger?.warn('member work sync nudge outbox payload conflict', { teamName: status.teamName, memberName: status.memberName, @@ -58,11 +153,108 @@ export class MemberWorkSyncNudgeOutboxPlanner { return { planned: false, code: 'payload_conflict' }; } + if (input.payload.workSyncIntent === 'review_pickup' && result.item.status === 'delivered') { + const code = 'review_pickup_already_delivered_still_stuck' as const; + await this.appendReviewPickupEscalationAudit(status, code); + await this.appendPlanAudit(status, { planned: false, code }); + return { planned: false, code }; + } + if ( + input.payload.workSyncIntent === 'review_pickup' && + result.item.status === 'failed_terminal' + ) { + const code = 'review_pickup_delivery_failed_still_stuck' as const; + await this.appendReviewPickupEscalationAudit(status, code); + await this.appendPlanAudit(status, { planned: false, code }); + return { planned: false, code }; + } + const planResult = { planned: true, code: result.outcome } as const; await this.appendPlanAudit(status, planResult); return planResult; } + private async appendReviewPickupEscalationAudit( + status: MemberWorkSyncStatus, + reason: string + ): Promise { + await appendMemberWorkSyncAudit(this.deps, { + teamName: status.teamName, + memberName: status.memberName, + event: 'review_pickup_escalated', + source: 'nudge_planner', + agendaFingerprint: status.agenda.fingerprint, + state: status.state, + actionableCount: status.agenda.items.length, + reason, + ...(status.providerId ? { providerId: status.providerId } : {}), + taskRefs: status.agenda.items.map((item) => ({ + taskId: item.taskId, + displayId: item.displayId, + teamName: status.teamName, + })), + }); + await this.notifyReviewPickupEscalation(status, reason); + } + + private async appendReviewPickupDeliveryUnavailableAudit( + status: MemberWorkSyncStatus, + diagnostics: string[] + ): Promise { + await appendMemberWorkSyncAudit(this.deps, { + teamName: status.teamName, + memberName: status.memberName, + event: 'review_pickup_delivery_unavailable', + source: 'nudge_planner', + agendaFingerprint: status.agenda.fingerprint, + state: status.state, + actionableCount: status.agenda.items.length, + reason: diagnostics[0], + diagnostics, + ...(status.providerId ? { providerId: status.providerId } : {}), + taskRefs: status.agenda.items.map((item) => ({ + taskId: item.taskId, + displayId: item.displayId, + teamName: status.teamName, + })), + }); + await this.appendReviewPickupEscalationAudit(status, diagnostics[0]); + } + + private async notifyReviewPickupEscalation( + status: MemberWorkSyncStatus, + reason: string + ): Promise { + const escalation = this.deps.reviewPickupEscalation; + if (!escalation) { + return; + } + + try { + await escalation.escalate({ + teamName: status.teamName, + memberName: status.memberName, + reason, + nowIso: status.evaluatedAt, + agendaFingerprint: status.agenda.fingerprint, + reviewRequestEventIds: getReviewRequestEventIds(status), + diagnostics: status.diagnostics, + taskRefs: status.agenda.items.map((item) => ({ + taskId: item.taskId, + displayId: item.displayId, + teamName: status.teamName, + })), + }); + } catch (error) { + this.deps.logger?.warn('member work sync review pickup escalation failed', { + teamName: status.teamName, + memberName: status.memberName, + reason, + error: String(error), + }); + } + } + private async appendPlanAudit( status: MemberWorkSyncStatus, result: MemberWorkSyncNudgeOutboxPlanResult diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index b7d06c74..efbb7d9d 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -86,6 +86,10 @@ export type MemberWorkSyncAuditEventName = | 'nudge_skipped' | 'nudge_retryable' | 'nudge_superseded' + | 'review_pickup_delivery_unavailable' + | 'review_pickup_member_nudge_delivered' + | 'review_pickup_escalated' + | 'review_pickup_wake_failed_retryable' | 'watchdog_cooldown_active' | 'member_busy' | 'team_inactive' @@ -152,6 +156,11 @@ export interface MemberWorkSyncOutboxStorePort { markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise; markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise; countRecentDelivered(input: MemberWorkSyncOutboxCountRecentDeliveredInput): Promise; + findDeliveredReviewPickupRequestEventIds?(input: { + teamName: string; + memberName: string; + reviewRequestEventIds: string[]; + }): Promise; } export interface MemberWorkSyncInboxNudgePort { @@ -193,6 +202,56 @@ export interface MemberWorkSyncNudgeDeliveryWakePort { }): Promise | void; } +export type MemberWorkSyncReviewPickupDeliveryOutcome = + | { + ok: true; + state: 'prompt_accepted' | 'response_proven'; + messageId: string; + diagnostics?: string[]; + } + | { + ok: false; + reason: 'capability_absent' | 'retryable_failure' | 'terminal_failure'; + message: string; + diagnostics?: string[]; + retryAfterIso?: string; + }; + +export interface MemberWorkSyncReviewPickupDeliveryPort { + canDeliver(input: { + teamName: string; + memberName: string; + providerId?: MemberWorkSyncProviderId | null; + }): + | Promise<{ ok: true } | { ok: false; reason: string; diagnostics?: string[] }> + | { + ok: true; + } + | { ok: false; reason: string; diagnostics?: string[] }; + deliver(input: { + teamName: string; + memberName: string; + messageId: string; + providerId?: MemberWorkSyncProviderId | null; + payload: MemberWorkSyncOutboxItem['payload']; + inserted: boolean; + nowIso: string; + }): Promise; +} + +export interface MemberWorkSyncReviewPickupEscalationPort { + escalate(input: { + teamName: string; + memberName: string; + reason: string; + nowIso: string; + agendaFingerprint?: string; + reviewRequestEventIds?: string[]; + diagnostics?: string[]; + taskRefs: { taskId: string; displayId?: string; teamName?: string }[]; + }): Promise | void; +} + export interface MemberWorkSyncUseCaseDeps { clock: MemberWorkSyncClockPort; hash: MemberWorkSyncHashPort; @@ -204,6 +263,8 @@ export interface MemberWorkSyncUseCaseDeps { watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort; busySignal?: MemberWorkSyncBusySignalPort; nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort; + reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort; + reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort; reportToken?: MemberWorkSyncReportTokenPort; auditJournal?: MemberWorkSyncAuditJournalPort; lifecycle?: MemberWorkSyncLifecyclePort; diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index 94022fff..f40d80ff 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -10,7 +10,7 @@ import { canonicalizeAgendaFingerprintPayload, formatAgendaFingerprint, } from './AgendaFingerprint'; -import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle'; +import { resolveCurrentReviewCycle, type ReviewHistoryEventLike } from './currentReviewCycle'; import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; import type { @@ -180,15 +180,44 @@ export function buildActionableWorkAgenda( continue; } - const reviewOwner = isReviewWorkflow - ? resolveCurrentReviewOwner({ + const reviewCycle = isReviewWorkflow + ? resolveCurrentReviewCycle({ reviewState: workflowColumn, kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null, historyEvents: task.historyEvents, }) : null; + const isSelfReview = + Boolean(owner) && + Boolean(reviewCycle?.reviewer) && + sameMemberName(owner, reviewCycle?.reviewer); - if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) { + if (isSelfReview && activeLeadName && sameMemberName(activeLeadName, memberName)) { + items.push({ + ...base, + kind: 'clarification', + priority: 'needs_clarification', + reason: 'self_review_invalid', + evidence: { + status: task.status, + owner, + reviewer: reviewCycle?.reviewer, + reviewState: workflowColumn, + ...(reviewCycle?.reviewRequestEventId + ? { reviewRequestEventId: reviewCycle.reviewRequestEventId } + : {}), + ...(reviewCycle?.historyEventIds.length + ? { historyEventIds: reviewCycle.historyEventIds } + : {}), + reviewDiagnostics: [ + ...new Set([...(reviewCycle?.diagnostics ?? []), 'self_review_invalid']), + ].sort(), + }, + }); + continue; + } + + if (reviewCycle && !isSelfReview && sameMemberName(reviewCycle.reviewer, memberName)) { items.push({ ...base, kind: 'review', @@ -198,9 +227,30 @@ export function buildActionableWorkAgenda( status: task.status, ...(owner ? { owner } : {}), reviewer: memberName, - ...(task.reviewState ? { reviewState: task.reviewState } : {}), - ...(reviewOwner.historyEventIds.length > 0 - ? { historyEventIds: reviewOwner.historyEventIds } + reviewState: workflowColumn, + reviewCycleId: reviewCycle.reviewCycleId, + reviewObligation: reviewCycle.obligation, + canBypassPhase2: reviewCycle.canBypassPhase2, + ...(reviewCycle.reviewRequestEventId + ? { reviewRequestEventId: reviewCycle.reviewRequestEventId } + : {}), + ...(reviewCycle.reviewRequestedAt + ? { reviewRequestedAt: reviewCycle.reviewRequestedAt } + : {}), + ...(reviewCycle.reviewStartedEventId + ? { reviewStartedEventId: reviewCycle.reviewStartedEventId } + : {}), + ...(reviewCycle.reviewStartedAt + ? { reviewStartedAt: reviewCycle.reviewStartedAt } + : {}), + ...(reviewCycle.reviewStartedBy + ? { reviewStartedBy: reviewCycle.reviewStartedBy } + : {}), + ...(reviewCycle.historyEventIds.length > 0 + ? { historyEventIds: reviewCycle.historyEventIds } + : {}), + ...(reviewCycle.diagnostics.length > 0 + ? { reviewDiagnostics: [...reviewCycle.diagnostics].sort() } : {}), }, }); diff --git a/src/features/member-work-sync/core/domain/AgendaFingerprint.ts b/src/features/member-work-sync/core/domain/AgendaFingerprint.ts index 8a66a7bd..0e246d6c 100644 --- a/src/features/member-work-sync/core/domain/AgendaFingerprint.ts +++ b/src/features/member-work-sync/core/domain/AgendaFingerprint.ts @@ -66,6 +66,9 @@ export function buildAgendaFingerprintPayload(input: { ...(item.evidence.historyEventIds ? { historyEventIds: [...item.evidence.historyEventIds].sort() } : {}), + ...(item.evidence.reviewDiagnostics + ? { reviewDiagnostics: [...item.evidence.reviewDiagnostics].sort() } + : {}), }, })), }; diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts index 5fc61d7d..b6d9145f 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts @@ -29,18 +29,100 @@ export function buildMemberWorkSyncNudgeId(input: { teamName: string; memberName: string; agendaFingerprint: string; + intentKey?: string; }): string { return [ MEMBER_WORK_SYNC_NUDGE_ID_PREFIX, input.teamName, input.memberName.trim().toLowerCase(), - input.agendaFingerprint, + input.intentKey ?? input.agendaFingerprint, ].join(':'); } +function getReviewPickupRequestEventIds(status: MemberWorkSyncStatus): string[] { + return [ + ...new Set( + status.agenda.items + .map((item) => item.evidence.reviewRequestEventId?.trim()) + .filter((id): id is string => Boolean(id)) + ), + ].sort(); +} + +function isReviewPickupNudgeStatus(status: MemberWorkSyncStatus): boolean { + return ( + status.agenda.items.length > 0 && + status.agenda.items.every( + (item) => + item.kind === 'review' && + item.evidence.reviewObligation === 'review_pickup_required' && + item.evidence.canBypassPhase2 === true && + typeof item.evidence.reviewRequestEventId === 'string' && + item.evidence.reviewRequestEventId.length > 0 && + (item.evidence.reviewDiagnostics?.length ?? 0) === 0 + ) + ); +} + +export function buildMemberWorkSyncReviewPickupIntentKey( + status: MemberWorkSyncStatus +): string | null { + const reviewRequestEventIds = getReviewPickupRequestEventIds(status); + return reviewRequestEventIds.length > 0 + ? `review-pickup:${reviewRequestEventIds.join('+')}` + : null; +} + +function buildTaskRefs(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload['taskRefs'] { + return status.agenda.items.map((item) => ({ + teamName: status.teamName, + taskId: item.taskId, + displayId: item.displayId ?? item.taskId.slice(0, 8), + })); +} + +function buildAgendaPreview(status: MemberWorkSyncStatus): string { + return status.agenda.items + .slice(0, 3) + .map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`) + .join('; '); +} + +function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload { + const taskRefs = buildTaskRefs(status); + const preview = buildAgendaPreview(status); + const reviewRequestEventIds = getReviewPickupRequestEventIds(status); + const intentKey = buildMemberWorkSyncReviewPickupIntentKey(status); + + return { + from: 'system', + to: status.memberName, + messageKind: 'member_work_sync_nudge', + source: 'member-work-sync', + actionMode: 'do', + workSyncIntent: 'review_pickup', + ...(intentKey ? { workSyncIntentKey: intentKey } : {}), + workSyncReviewRequestEventIds: reviewRequestEventIds, + taskRefs, + text: [ + 'Review pickup required: a current review request is waiting for you.', + preview ? `Review agenda: ${preview}.` : '', + 'Open the task, verify the current reviewState/status, then start or continue the review only if it is still assigned to you.', + `If you cannot pick it up now, call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then report "blocked" or "still_working" only for the real current state.`, + 'Do not mark the review complete from this prompt alone, and do not reply only with acknowledgement.', + ] + .filter(Boolean) + .join('\n'), + }; +} + export function buildMemberWorkSyncNudgePayload( status: MemberWorkSyncStatus ): MemberWorkSyncNudgePayload { + if (isReviewPickupNudgeStatus(status)) { + return buildReviewPickupNudgePayload(status); + } + const taskRefs = status.agenda.items.map((item) => ({ teamName: status.teamName, taskId: item.taskId, @@ -58,6 +140,7 @@ export function buildMemberWorkSyncNudgePayload( messageKind: 'member_work_sync_nudge', source: 'member-work-sync', actionMode: 'do', + workSyncIntent: 'agenda_sync', taskRefs, text: [ 'Work sync check: you have current actionable work assigned.', @@ -98,11 +181,14 @@ export function buildMemberWorkSyncOutboxEnsureInput(input: { } const payload = buildMemberWorkSyncNudgePayload(status); + const intentKey = + payload.workSyncIntent === 'review_pickup' ? payload.workSyncIntentKey : undefined; return { id: buildMemberWorkSyncNudgeId({ teamName: status.teamName, memberName: status.memberName, agendaFingerprint: status.agenda.fingerprint, + intentKey, }), teamName: status.teamName, memberName: status.memberName, diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts index f763fb74..ba790bf5 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts @@ -18,20 +18,45 @@ export type MemberWorkSyncReportTokenValidation = | { ok: false; reason: 'missing' | 'expired' | 'invalid' }; const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000; +const DEFAULT_REVIEW_PICKUP_STILL_WORKING_LEASE_MS = 3 * 60 * 1000; const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000; const MIN_LEASE_MS = 60_000; const MAX_LEASE_MS = 60 * 60 * 1000; +const MAX_REVIEW_PICKUP_STILL_WORKING_LEASE_MS = 10 * 60 * 1000; + +function agendaIsReviewPickupRequired(agenda: MemberWorkSyncAgenda): boolean { + return ( + agenda.items.length > 0 && + agenda.items.every( + (item) => + item.kind === 'review' && + item.evidence.reviewObligation === 'review_pickup_required' && + item.evidence.canBypassPhase2 === true + ) + ); +} function clampLeaseTtlMs( value: number | undefined, - state: MemberWorkSyncReportState + state: MemberWorkSyncReportState, + agenda: MemberWorkSyncAgenda ): number | undefined { if (state === 'caught_up') { return undefined; } - const fallback = state === 'blocked' ? DEFAULT_BLOCKED_LEASE_MS : DEFAULT_STILL_WORKING_LEASE_MS; + const isReviewPickupStillWorking = + state === 'still_working' && agendaIsReviewPickupRequired(agenda); + const fallback = + state === 'blocked' + ? DEFAULT_BLOCKED_LEASE_MS + : isReviewPickupStillWorking + ? DEFAULT_REVIEW_PICKUP_STILL_WORKING_LEASE_MS + : DEFAULT_STILL_WORKING_LEASE_MS; + const maxLease = isReviewPickupStillWorking + ? MAX_REVIEW_PICKUP_STILL_WORKING_LEASE_MS + : MAX_LEASE_MS; const numeric = Number.isFinite(value) ? Math.floor(Number(value)) : fallback; - return Math.min(MAX_LEASE_MS, Math.max(MIN_LEASE_MS, numeric)); + return Math.min(maxLease, Math.max(MIN_LEASE_MS, numeric)); } function agendaHasBlockedEvidence( @@ -139,7 +164,7 @@ export function validateMemberWorkSyncReport(input: { }; } - const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state); + const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state, input.agenda); return { ok: true, code: 'accepted', diff --git a/src/features/member-work-sync/core/domain/currentReviewCycle.ts b/src/features/member-work-sync/core/domain/currentReviewCycle.ts index f5b4cdd9..951ff961 100644 --- a/src/features/member-work-sync/core/domain/currentReviewCycle.ts +++ b/src/features/member-work-sync/core/domain/currentReviewCycle.ts @@ -10,21 +10,214 @@ export interface ReviewHistoryEventLike { to?: string; } +export type CurrentReviewObligation = 'review_pickup_required' | 'review_in_progress'; + +export interface CurrentReviewCycle { + reviewer: string; + obligation: CurrentReviewObligation; + reviewCycleId: string; + historyEventIds: string[]; + reviewRequestEventId?: string; + reviewRequestedAt?: string; + reviewStartedEventId?: string; + reviewStartedAt?: string; + reviewStartedBy?: string; + canBypassPhase2: boolean; + diagnostics: string[]; +} + export interface CurrentReviewOwner { reviewer: string; historyEventIds: string[]; } -function compareEventsByTimestamp( - left: ReviewHistoryEventLike, - right: ReviewHistoryEventLike -): number { - const leftTime = Date.parse(left.timestamp ?? ''); - const rightTime = Date.parse(right.timestamp ?? ''); - if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { +interface IndexedReviewEvent { + event: ReviewHistoryEventLike; + index: number; +} + +interface OpenReviewCycleEvidence { + request?: IndexedReviewEvent; + started?: IndexedReviewEvent; +} + +const REVIEW_EVENT_TYPES = new Set([ + 'review_requested', + 'review_started', + 'review_approved', + 'review_changes_requested', + 'task_created', + 'status_changed', +]); + +function eventTimestampMs(event: ReviewHistoryEventLike): number | null { + const parsed = Date.parse(event.timestamp ?? ''); + return Number.isFinite(parsed) ? parsed : null; +} + +function compareIndexedEvents(left: IndexedReviewEvent, right: IndexedReviewEvent): number { + const leftTime = eventTimestampMs(left.event); + const rightTime = eventTimestampMs(right.event); + if (leftTime !== null && rightTime !== null && leftTime !== rightTime) { return leftTime - rightTime; } - return 0; + return left.index - right.index; +} + +function historyEventId(event?: IndexedReviewEvent): string | undefined { + const id = event?.event.id?.trim(); + return id || undefined; +} + +function historyEventTimestamp(event?: IndexedReviewEvent): string | undefined { + const timestamp = event?.event.timestamp?.trim(); + return timestamp || undefined; +} + +function uniqueIds(ids: (string | undefined)[]): string[] { + return [...new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0))]; +} + +function isReviewCycleBoundary(event: ReviewHistoryEventLike): boolean { + if (event.type === 'task_created') { + return true; + } + if (event.type === 'status_changed') { + return event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted'; + } + return false; +} + +function collectOpenReviewCycle( + historyEvents: ReviewHistoryEventLike[] +): OpenReviewCycleEvidence | null { + let openCycle: OpenReviewCycleEvidence | null = null; + const sortedEvents = historyEvents + .map((event, index) => ({ event, index })) + .filter(({ event }) => REVIEW_EVENT_TYPES.has(event.type)) + .sort(compareIndexedEvents); + + for (const item of sortedEvents) { + const { event } = item; + if (isReviewCycleBoundary(event)) { + openCycle = null; + continue; + } + + if (event.type === 'review_requested') { + openCycle = { request: item }; + continue; + } + + if (event.type === 'review_started') { + openCycle = openCycle ? { ...openCycle, started: item } : { started: item }; + continue; + } + + if (event.type === 'review_approved' || event.type === 'review_changes_requested') { + openCycle = null; + } + } + + return openCycle; +} + +export function resolveCurrentReviewCycle(input: { + reviewState?: string | null; + kanbanReviewer?: string | null; + historyEvents?: ReviewHistoryEventLike[]; +}): CurrentReviewCycle | null { + if (input.reviewState !== 'review') { + return null; + } + + const kanbanReviewer = normalizeMemberName(input.kanbanReviewer); + const openCycle = collectOpenReviewCycle(input.historyEvents ?? []); + const diagnostics: string[] = []; + + if (!openCycle) { + if (!kanbanReviewer) { + return null; + } + diagnostics.push('legacy_kanban_reviewer_without_current_review_cycle'); + return { + reviewer: kanbanReviewer, + obligation: 'review_in_progress', + reviewCycleId: `kanban:${kanbanReviewer}`, + historyEventIds: [], + canBypassPhase2: false, + diagnostics, + }; + } + + const requestReviewer = normalizeMemberName(openCycle.request?.event.reviewer); + const startedBy = normalizeMemberName(openCycle.started?.event.actor); + const reviewer = requestReviewer || kanbanReviewer || startedBy; + + if (!reviewer) { + return null; + } + + const requestEventId = historyEventId(openCycle.request); + const startedEventId = historyEventId(openCycle.started); + const obligation: CurrentReviewObligation = openCycle.started + ? 'review_in_progress' + : 'review_pickup_required'; + + if (openCycle.request && !requestEventId) { + diagnostics.push('review_request_event_id_missing'); + } + if (openCycle.request && !requestReviewer) { + diagnostics.push('review_request_reviewer_missing'); + } + if (!openCycle.request && openCycle.started) { + diagnostics.push('review_started_without_review_request'); + } + if (openCycle.started && !startedBy) { + diagnostics.push('review_started_actor_missing'); + } + if ( + openCycle.request && + openCycle.started && + requestReviewer && + startedBy && + requestReviewer !== startedBy + ) { + diagnostics.push('review_started_actor_differs_from_requested_reviewer'); + } + if ( + openCycle.request && + requestReviewer && + kanbanReviewer && + requestReviewer !== kanbanReviewer + ) { + diagnostics.push('kanban_reviewer_differs_from_review_request'); + } + if (openCycle.started && startedBy && kanbanReviewer && startedBy !== kanbanReviewer) { + diagnostics.push('kanban_reviewer_differs_from_review_started_actor'); + } + + const reviewCycleId = requestEventId ?? startedEventId ?? `kanban:${reviewer}`; + const canBypassPhase2 = + obligation === 'review_pickup_required' && Boolean(requestEventId) && diagnostics.length === 0; + + return { + reviewer, + obligation, + reviewCycleId, + historyEventIds: uniqueIds([requestEventId, startedEventId]), + ...(requestEventId ? { reviewRequestEventId: requestEventId } : {}), + ...(historyEventTimestamp(openCycle.request) + ? { reviewRequestedAt: historyEventTimestamp(openCycle.request) } + : {}), + ...(startedEventId ? { reviewStartedEventId: startedEventId } : {}), + ...(historyEventTimestamp(openCycle.started) + ? { reviewStartedAt: historyEventTimestamp(openCycle.started) } + : {}), + ...(startedBy ? { reviewStartedBy: startedBy } : {}), + canBypassPhase2, + diagnostics, + }; } export function resolveCurrentReviewOwner(input: { @@ -32,52 +225,11 @@ export function resolveCurrentReviewOwner(input: { kanbanReviewer?: string | null; historyEvents?: ReviewHistoryEventLike[]; }): CurrentReviewOwner | null { - if (input.reviewState !== 'review') { - return null; - } - - const historyEvents = [...(input.historyEvents ?? [])] - .filter((event) => - [ - 'review_requested', - 'review_started', - 'review_approved', - 'review_changes_requested', - ].includes(event.type) - ) - .sort(compareEventsByTimestamp); - - const latest = historyEvents.at(-1); - if (latest?.type === 'review_approved' || latest?.type === 'review_changes_requested') { - return null; - } - - const kanbanReviewer = normalizeMemberName(input.kanbanReviewer); - if (kanbanReviewer) { - return { - reviewer: kanbanReviewer, - historyEventIds: [], - }; - } - - const latestStarted = [...historyEvents] - .reverse() - .find((event) => event.type === 'review_started'); - const latestRequested = [...historyEvents] - .reverse() - .find((event) => event.type === 'review_requested'); - - const reviewer = - normalizeMemberName(latestStarted?.actor) || normalizeMemberName(latestRequested?.reviewer); - - if (!reviewer) { - return null; - } - - return { - reviewer, - historyEventIds: [latestStarted?.id, latestRequested?.id].filter( - (id): id is string => typeof id === 'string' && id.length > 0 - ), - }; + const cycle = resolveCurrentReviewCycle(input); + return cycle + ? { + reviewer: cycle.reviewer, + historyEventIds: cycle.historyEventIds, + } + : null; } diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts index 3163755e..32af1164 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts @@ -4,7 +4,11 @@ import { isTeamTaskTerminalForActionableWork, } from '@shared/utils/teamTaskState'; -import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain'; +import { + normalizeMemberName, + resolveCurrentReviewOwner, + sameMemberName, +} from '../../../core/domain'; import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; @@ -144,12 +148,22 @@ export class MemberWorkSyncTaskImpactResolver { const reviewOwner = taskWorkflowColumn === 'review' ? resolveCurrentReviewOwner({ - reviewState: task.reviewState, + reviewState: taskWorkflowColumn, kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null, historyEvents: task.historyEvents, }) : null; - addMember(reviewOwner?.reviewer); + const selfReview = + taskWorkflowColumn === 'review' && + Boolean(reviewOwner?.reviewer) && + Boolean(normalizeMemberName(task.owner)) && + sameMemberName(reviewOwner?.reviewer, task.owner); + if (selfReview) { + addLead(); + addDiagnostic('self_review_invalid'); + } else { + addMember(reviewOwner?.reviewer); + } if (taskWorkflowColumn === 'review' && !reviewOwner?.reviewer) { addLead(); diff --git a/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts b/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts index 15cd418d..3415c22a 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts @@ -27,6 +27,9 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg summary: 'Work sync check', source: 'system_notification', messageKind: input.payload.messageKind, + workSyncIntent: input.payload.workSyncIntent, + workSyncIntentKey: input.payload.workSyncIntentKey, + workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds, }); return { diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 6070054b..01b41483 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -51,6 +51,8 @@ import type { MemberWorkSyncBusySignalPort, MemberWorkSyncLoggerPort, MemberWorkSyncNudgeDeliveryWakePort, + MemberWorkSyncReviewPickupDeliveryPort, + MemberWorkSyncReviewPickupEscalationPort, } from '../../core/application'; import type { RuntimeTurnSettledProvider } from '../../core/domain'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; @@ -59,6 +61,34 @@ import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaSt import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; import type { TeamChangeEvent } from '@shared/types'; +const STALE_STATUS_MAX_AGE_MS = 2 * 60_000; + +function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] { + const diagnostics: string[] = []; + const evaluatedAtMs = Date.parse(status.evaluatedAt); + if (!Number.isFinite(evaluatedAtMs)) { + diagnostics.push('status_evaluated_at_invalid'); + } else if ( + status.agenda.items.length > 0 && + ['needs_sync', 'still_working', 'blocked'].includes(status.state) && + nowMs - evaluatedAtMs > STALE_STATUS_MAX_AGE_MS + ) { + diagnostics.push('status_stale_refresh_enqueued'); + } + + const reportExpiresAtMs = Date.parse(status.report?.expiresAt ?? ''); + if ( + status.report?.accepted && + Number.isFinite(reportExpiresAtMs) && + reportExpiresAtMs <= nowMs && + (status.state === 'still_working' || status.state === 'blocked') + ) { + diagnostics.push('accepted_report_lease_expired_refresh_enqueued'); + } + + return [...new Set(diagnostics)]; +} + export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: { teamsBasePath: string; provider: RuntimeTurnSettledProvider; @@ -100,6 +130,8 @@ export function createMemberWorkSyncFeature(deps: { runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort; extraBusySignals?: MemberWorkSyncBusySignalPort[]; nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort; + reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort; + reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -163,6 +195,8 @@ export function createMemberWorkSyncFeature(deps: { watchdogCooldown, busySignal, ...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}), + ...(deps.reviewPickupDelivery ? { reviewPickupDelivery: deps.reviewPickupDelivery } : {}), + ...(deps.reviewPickupEscalation ? { reviewPickupEscalation: deps.reviewPickupEscalation } : {}), reportToken, auditJournal, ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), @@ -240,8 +274,27 @@ export function createMemberWorkSyncFeature(deps: { runtimeTurnSettledDrainScheduler.start(); nudgeDispatchScheduler?.start(); + const readStatusWithStaleRefresh = async ( + request: MemberWorkSyncStatusRequest + ): Promise => { + const status = await diagnosticsReader.execute(request); + const stalenessDiagnostics = getStatusStalenessDiagnostics(status, clock.now().getTime()); + if (stalenessDiagnostics.length === 0) { + return status; + } + queue.enqueue({ + teamName: status.teamName, + memberName: status.memberName, + triggerReason: 'manual_refresh', + }); + return { + ...status, + diagnostics: [...new Set([...status.diagnostics, ...stalenessDiagnostics])], + }; + }; + return { - getStatus: (request) => diagnosticsReader.execute(request), + getStatus: readStatusWithStaleRefresh, refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }), getMetrics: (request) => metricsReader.execute(request), report: (request) => reporter.execute(request), diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index 7fb1901c..245db293 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -252,6 +252,30 @@ function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boo return item.nextAttemptAt <= nowIso; } +function getReviewPickupIntentKey(item: Pick): string | null { + if (item.payload.workSyncIntent !== 'review_pickup') { + return null; + } + const explicit = item.payload.workSyncIntentKey?.trim(); + if (explicit) { + return explicit; + } + const requestEventIds = [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])] + .map((id) => id.trim()) + .filter(Boolean) + .sort(); + return requestEventIds.length > 0 ? `review-pickup:${requestEventIds.join('+')}` : null; +} + +function isSameReviewPickupIntent( + current: MemberWorkSyncOutboxItem, + input: MemberWorkSyncOutboxEnsureInput +): boolean { + const currentIntentKey = getReviewPickupIntentKey(current); + const inputIntentKey = getReviewPickupIntentKey({ payload: input.payload }); + return Boolean(currentIntentKey && inputIntentKey && currentIntentKey === inputIntentKey); +} + function getDueOutboxRoutes( index: OutboxIndexFile, nowIso: string, @@ -697,6 +721,30 @@ export class JsonMemberWorkSyncStore const current = outbox.items[input.id]; if (current) { if (current.payloadHash !== input.payloadHash) { + if (isSameReviewPickupIntent(current, input) && !isOutboxTerminal(current.status)) { + const next: MemberWorkSyncOutboxItem = { + ...current, + agendaFingerprint: input.agendaFingerprint, + payloadHash: input.payloadHash, + payload: input.payload, + status: 'pending', + updatedAt: input.nowIso, + }; + const nextAttemptAt = input.nextAttemptAt ?? current.nextAttemptAt; + if (nextAttemptAt) { + next.nextAttemptAt = nextAttemptAt; + } else { + delete next.nextAttemptAt; + } + delete next.claimedBy; + delete next.claimedAt; + delete next.lastError; + outbox.items[input.id] = next; + await this.writeMemberOutboxFile(input.teamName, input.memberName, outbox); + await this.upsertOutboxIndexItem(input.teamName, next, memberKey); + result = { ok: true, outcome: 'existing', item: next }; + return; + } result = { ok: false, outcome: 'payload_conflict', @@ -828,6 +876,10 @@ export class JsonMemberWorkSyncStore ...current, status: 'delivered', deliveredMessageId: input.deliveredMessageId, + ...(input.deliveryState ? { deliveryState: input.deliveryState } : {}), + ...(input.deliveryDiagnostics?.length + ? { deliveryDiagnostics: input.deliveryDiagnostics } + : {}), updatedAt: input.nowIso, }; delete next.lastError; @@ -902,6 +954,32 @@ export class JsonMemberWorkSyncStore return Math.max(indexedCount, memberFileCount); } + async findDeliveredReviewPickupRequestEventIds(input: { + teamName: string; + memberName: string; + reviewRequestEventIds: string[]; + }): Promise { + const requested = new Set(input.reviewRequestEventIds.map((id) => id.trim()).filter(Boolean)); + if (requested.size === 0) { + return []; + } + + const memberOutbox = await this.readMemberOutboxFile(input.teamName, input.memberName); + const delivered = new Set(); + for (const item of Object.values(memberOutbox.items)) { + if (item.status !== 'delivered' || item.payload.workSyncIntent !== 'review_pickup') { + continue; + } + for (const eventId of item.payload.workSyncReviewRequestEventIds ?? []) { + const normalized = eventId.trim(); + if (requested.has(normalized)) { + delivered.add(normalized); + } + } + } + return [...delivered].sort(); + } + private async readLegacyStatusFile(teamName: string): Promise { return readJsonFile( this.paths.getLegacyStatusPath(teamName), diff --git a/src/main/index.ts b/src/main/index.ts index c716995d..388c89ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -101,6 +101,7 @@ import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idl import { parseInboxJson } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; +import { createHash } from 'crypto'; import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -248,6 +249,7 @@ if (process.platform === 'win32') { // --- Team message notification tracking --- const teamInboxReader = new TeamInboxReader(); +const teamInboxWriter = new TeamInboxWriter(); const sentMessagesStore = new TeamSentMessagesStore(); /** Track last-seen message count per inbox file to detect new messages. */ const inboxMessageCounts = new Map(); @@ -256,9 +258,58 @@ const sentMessageCounts = new Map(); /** Debounce per-inbox to avoid flooding during batch writes. */ const inboxNotifyTimers = new Map>(); const INBOX_NOTIFY_DEBOUNCE_MS = 500; -/** Messages sent from our UI (user_sent) — suppress notifications for these. */ +/** Messages sent from our UI (user_sent) - suppress notifications for these. */ const suppressedSources = new Set(['user_sent']); +function buildMemberWorkSyncReviewPickupEscalationMessageId(input: { + teamName: string; + memberName: string; + reason: string; + reviewRequestEventIds?: readonly string[]; + taskRefs: readonly { taskId: string; displayId?: string }[]; +}): string { + const stableKey = JSON.stringify({ + teamName: input.teamName, + memberName: input.memberName.trim().toLowerCase(), + reason: input.reason, + reviewRequestEventIds: [...new Set(input.reviewRequestEventIds ?? [])].sort(), + taskIds: [...new Set(input.taskRefs.map((taskRef) => taskRef.taskId).filter(Boolean))].sort(), + }); + const digest = createHash('sha256').update(stableKey).digest('hex').slice(0, 20); + return `member-work-sync-review-pickup-escalation:${digest}`; +} + +function buildMemberWorkSyncReviewPickupEscalationText(input: { + memberName: string; + reason: string; + diagnostics?: readonly string[]; + taskRefs: readonly { taskId: string; displayId?: string }[]; +}): string { + const taskLines = input.taskRefs.length + ? input.taskRefs + .map( + (taskRef) => `- ${taskRef.displayId ?? taskRef.taskId.slice(0, 8)} (${taskRef.taskId})` + ) + .join('\n') + : '- No task refs recorded'; + const diagnostics = [...new Set(input.diagnostics ?? [])].filter(Boolean); + return [ + 'Review pickup still pending in member work-sync.', + '', + `Reviewer: ${input.memberName}`, + `Reason: ${input.reason}`, + '', + 'Tasks:', + taskLines, + '', + 'No review_start, review_approve, or review_request_changes was recorded for the current review request after the member correction path.', + 'Consider reassigning the reviewer or sending a direct instruction.', + diagnostics.length ? `Diagnostics: ${diagnostics.join(', ')}` : '', + ] + .filter(Boolean) + .join('\n'); +} + async function createOpenCodeRuntimeAdapterRegistry( reportProgress: (phase: string, message: string) => void = () => undefined ): Promise { @@ -1553,6 +1604,108 @@ async function initializeServices(): Promise { }); }, }, + reviewPickupDelivery: { + canDeliver: (input) => + input.providerId === 'opencode' + ? { ok: true } + : { + ok: false, + reason: `provider_not_supported:${input.providerId ?? 'unknown'}`, + }, + deliver: async (input) => { + if (input.providerId !== 'opencode') { + return { + ok: false, + reason: 'capability_absent', + message: `provider_not_supported:${input.providerId ?? 'unknown'}`, + }; + } + + const relay = await teamProvisioningService.relayOpenCodeMemberInboxMessages( + input.teamName, + input.memberName, + { + onlyMessageId: input.messageId, + source: 'member-work-sync-review-pickup', + deliveryMetadata: { + actionMode: input.payload.actionMode, + taskRefs: input.payload.taskRefs, + }, + } + ); + const lastDelivery = relay.lastDelivery; + const diagnostics = [...(relay.diagnostics ?? []), ...(lastDelivery?.diagnostics ?? [])]; + if (lastDelivery?.accepted === true && lastDelivery.responsePending === true) { + return { + ok: true, + state: 'prompt_accepted', + messageId: input.messageId, + diagnostics, + }; + } + if (lastDelivery?.delivered && lastDelivery.accepted !== false) { + return { + ok: true, + state: lastDelivery.responsePending ? 'prompt_accepted' : 'response_proven', + messageId: input.messageId, + diagnostics, + }; + } + if ( + lastDelivery?.reason === 'recipient_is_not_opencode' || + lastDelivery?.reason === 'recipient_removed' || + lastDelivery?.reason === 'opencode_recipient_unavailable' + ) { + return { + ok: false, + reason: 'capability_absent', + message: lastDelivery.reason, + diagnostics, + }; + } + if (lastDelivery?.ledgerStatus === 'failed_terminal') { + return { + ok: false, + reason: 'terminal_failure', + message: lastDelivery.reason ?? 'opencode_review_pickup_delivery_failed_terminal', + diagnostics, + }; + } + return { + ok: false, + reason: 'retryable_failure', + message: lastDelivery?.reason ?? 'opencode_review_pickup_delivery_not_confirmed', + diagnostics, + }; + }, + }, + reviewPickupEscalation: { + escalate: async (input) => { + const leadName = (await teamDataService.getLeadMemberName(input.teamName)) ?? 'team-lead'; + const messageId = buildMemberWorkSyncReviewPickupEscalationMessageId(input); + const existing = await teamInboxReader.getMessagesFor(input.teamName, leadName); + if (existing.some((message) => message.messageId === messageId)) { + return; + } + + await teamInboxWriter.sendMessage(input.teamName, { + member: leadName, + from: 'system', + to: leadName, + messageId, + timestamp: input.nowIso, + summary: 'Review pickup still pending', + text: buildMemberWorkSyncReviewPickupEscalationText(input), + taskRefs: input.taskRefs.map((taskRef) => ({ + taskId: taskRef.taskId, + displayId: taskRef.displayId ?? taskRef.taskId.slice(0, 8), + teamName: taskRef.teamName ?? input.teamName, + })), + actionMode: 'do', + source: 'system_notification', + }); + }, + }, logger: createLogger('Feature:MemberWorkSync'), }); teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) => diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index fd840add..f4bfeff1 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -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_RAW_REQUEST_LIMIT = 1_000; const TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT = 201; // --- Module-level state --- @@ -212,7 +213,7 @@ function sanitizeTeamTaskChangeSummaryRequests(requests: unknown): TeamTaskChang const sanitizedRequests: TeamTaskChangeSummaryRequest[] = []; const seenTaskIds = new Set(); - for (const request of requests) { + for (const request of requests.slice(0, TEAM_TASK_CHANGE_SUMMARY_IPC_RAW_REQUEST_LIMIT)) { if (sanitizedRequests.length >= TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT) { break; } diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index a0e2b362..feb1cf5f 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -57,6 +57,7 @@ const logger = createLogger('Service:ChangeExtractorService'); const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const; const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const; const OPEN_CODE_MAX_DISCOVERED_LANES = 500; +const TEAM_TASK_CHANGE_SUMMARY_BATCH_INSPECT_LIMIT = 1_000; const TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT = 200; const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3; @@ -228,14 +229,13 @@ export class ChangeExtractorService { const ledgerResult = await this.readLedgerTaskChanges(resolvedInput); if (ledgerResult) { - await this.recordTaskChangePresence( - teamName, - taskId, - taskMeta, - effectiveOptions, + const recoveredLedgerResult = await this.recoverWarningOnlyLedgerResult( + resolvedInput, ledgerResult ); - return ledgerResult; + const result = recoveredLedgerResult ?? ledgerResult; + await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); + return result; } const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput); @@ -339,7 +339,9 @@ export class ChangeExtractorService { const inputRequests = Array.isArray(requests) ? requests : []; const seenTaskIds = new Set(); const uniqueRequests: TeamTaskChangeSummaryRequest[] = []; - for (const request of inputRequests) { + let inspectedRequests = 0; + for (const request of inputRequests.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_INSPECT_LIMIT)) { + inspectedRequests += 1; if (!request || typeof request !== 'object') { continue; } @@ -349,6 +351,9 @@ export class ChangeExtractorService { } seenTaskIds.add(taskId); uniqueRequests.push({ ...request, taskId }); + if (uniqueRequests.length > TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT) { + break; + } } const cappedRequests = uniqueRequests.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT); const items: TeamTaskChangeSummaryItem[] = cappedRequests.map((request) => ({ @@ -391,7 +396,10 @@ export class ChangeExtractorService { teamName, items, computedAt: new Date().toISOString(), - truncated: uniqueRequests.length > cappedRequests.length || undefined, + truncated: + uniqueRequests.length > cappedRequests.length || inspectedRequests < inputRequests.length + ? true + : undefined, }; } @@ -471,6 +479,106 @@ export class ChangeExtractorService { } } + private async recoverWarningOnlyLedgerResult( + input: ResolvedTaskChangeComputeInput, + ledgerResult: TaskChangeSetV2 + ): Promise { + if (!this.shouldRecoverWarningOnlyLedgerResult(input, ledgerResult)) { + return null; + } + + const openCodeBackfill = await this.tryBackfillOpenCodeLedger(input); + if (openCodeBackfill.backfilled || openCodeBackfill.attempted) { + const backfilledLedgerResult = await this.readLedgerTaskChanges(input); + if (backfilledLedgerResult && backfilledLedgerResult.files.length > 0) { + return this.mergeWarningOnlyLedgerRecovery(ledgerResult, backfilledLedgerResult); + } + } + + if (!(await this.shouldUseLegacyWarningOnlyRecovery(input))) { + return null; + } + + const fallbackResult = await this.computeTaskChangesPreferred(input); + if (fallbackResult.files.length === 0) { + return null; + } + + return this.mergeWarningOnlyLedgerRecovery(ledgerResult, fallbackResult); + } + + private shouldRecoverWarningOnlyLedgerResult( + input: ResolvedTaskChangeComputeInput, + ledgerResult: TaskChangeSetV2 + ): boolean { + return ( + ledgerResult.provenance?.sourceKind === 'ledger' && + ledgerResult.files.length === 0 && + ledgerResult.warnings.some((warning) => warning.trim().length > 0) && + this.hasCompletedTaskWorkInterval(input) + ); + } + + private hasCompletedTaskWorkInterval(input: ResolvedTaskChangeComputeInput): boolean { + const intervals = input.effectiveOptions.intervals ?? input.taskMeta?.intervals ?? []; + return intervals.some( + (interval) => + typeof interval.startedAt === 'string' && + interval.startedAt.trim().length > 0 && + typeof interval.completedAt === 'string' && + interval.completedAt.trim().length > 0 + ); + } + + private async shouldUseLegacyWarningOnlyRecovery( + input: ResolvedTaskChangeComputeInput + ): Promise { + const providerId = await this.resolveTaskOwnerProviderId(input); + return providerId === 'codex'; + } + + private async resolveTaskOwnerProviderId( + input: ResolvedTaskChangeComputeInput + ): Promise { + const owner = (input.effectiveOptions.owner ?? input.taskMeta?.owner ?? '') + .trim() + .toLowerCase(); + if (!owner) { + return null; + } + + try { + const config = await this.readConfigForObservation(input.teamName); + const member = (config?.members ?? []).find( + (candidate) => candidate.name.trim().toLowerCase() === owner + ); + return member?.providerId ?? null; + } catch { + return null; + } + } + + private mergeWarningOnlyLedgerRecovery( + warningOnlyLedger: TaskChangeSetV2, + recovered: TaskChangeSetV2 + ): TaskChangeSetV2 { + return { + ...recovered, + warnings: this.mergeTaskChangeWarnings(warningOnlyLedger.warnings, recovered.warnings), + }; + } + + private mergeTaskChangeWarnings(...groups: string[][]): string[] { + const warnings = new Set(); + for (const group of groups) { + for (const warning of group) { + const trimmed = warning.trim(); + if (trimmed) warnings.add(trimmed); + } + } + return [...warnings]; + } + private async tryBackfillOpenCodeLedger( input: ResolvedTaskChangeComputeInput ): Promise { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index e329ff4f..7812a58c 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2312,6 +2312,9 @@ export class TeamDataService { toolSummary: enrichedRequest.toolSummary, toolCalls: enrichedRequest.toolCalls, messageKind: enrichedRequest.messageKind, + workSyncIntent: enrichedRequest.workSyncIntent, + workSyncIntentKey: enrichedRequest.workSyncIntentKey, + workSyncReviewRequestEventIds: enrichedRequest.workSyncReviewRequestEventIds, slashCommand: enrichedRequest.slashCommand, commandOutput: enrichedRequest.commandOutput, taskRefs: enrichedRequest.taskRefs, diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 9e64b9e2..2e952c96 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -148,6 +148,17 @@ export class TeamInboxReader { : row.messageKind === 'default' ? 'default' : undefined, + workSyncIntent: + row.workSyncIntent === 'agenda_sync' || row.workSyncIntent === 'review_pickup' + ? row.workSyncIntent + : undefined, + workSyncIntentKey: + typeof row.workSyncIntentKey === 'string' ? row.workSyncIntentKey : undefined, + workSyncReviewRequestEventIds: Array.isArray(row.workSyncReviewRequestEventIds) + ? row.workSyncReviewRequestEventIds.filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ) + : undefined, slashCommand: row.slashCommand && typeof row.slashCommand === 'object' && diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 8f3c33d8..addae1b6 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -72,6 +72,11 @@ export class TeamInboxWriter { ...(request.toolSummary && { toolSummary: request.toolSummary }), ...(request.toolCalls && { toolCalls: request.toolCalls }), ...(request.messageKind && { messageKind: request.messageKind }), + ...(request.workSyncIntent && { workSyncIntent: request.workSyncIntent }), + ...(request.workSyncIntentKey && { workSyncIntentKey: request.workSyncIntentKey }), + ...(request.workSyncReviewRequestEventIds?.length + ? { workSyncReviewRequestEventIds: request.workSyncReviewRequestEventIds } + : {}), ...(request.slashCommand && { slashCommand: request.slashCommand }), ...(request.commandOutput && { commandOutput: request.commandOutput }), }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8fa10120..68dbf237 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,11 +1,11 @@ import { + AgentAttachmentError, buildClaudeAttachmentDeliveryParts, buildCodexNativeAttachmentDeliveryParts, buildOpenCodeAttachmentDeliveryParts, type CodexNativeImageArgPart, type OpenCodeFilePart, } from '@features/agent-attachments/main'; -import { AgentAttachmentError } from '@features/agent-attachments/core/domain'; import { resolveAnthropicFastMode, resolveAnthropicRuntimeSelection, @@ -74,9 +74,9 @@ import { } from '@shared/constants/crossTeam'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { - DEFAULT_TOOL_APPROVAL_SETTINGS, type AttachmentMeta, type AttachmentPayload, + DEFAULT_TOOL_APPROVAL_SETTINGS, } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; @@ -203,8 +203,8 @@ import { decideOpenCodeRuntimeDeliveryAdvisory, isDeferredGenericOpenCodeRuntimeDeliveryReason, isPotentialOpenCodeRuntimeDeliveryError, - toOpenCodeRuntimeDeliveryUserVisibleImpact, type OpenCodeRuntimeDeliveryAdvisoryDecision, + toOpenCodeRuntimeDeliveryUserVisibleImpact, } from './opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy'; import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import { @@ -285,6 +285,7 @@ import { buildDesktopTeammateModeCliArgs, resolveDesktopTeammateModeDecision, } from './runtimeTeammateMode'; +import { TeamAttachmentStore } from './TeamAttachmentStore'; import { choosePreferredLaunchSnapshot, clearBootstrapState, @@ -293,9 +294,9 @@ import { readBootstrapRuntimeState, } from './TeamBootstrapStateReader'; import { TeamConfigReader } from './TeamConfigReader'; -import { TeamAttachmentStore } from './TeamAttachmentStore'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; +import { writeTeamLaunchFailureArtifactPack } from './TeamLaunchFailureArtifactPack'; import { createPersistedLaunchSnapshot, deriveTeamLaunchAggregateState, @@ -304,7 +305,6 @@ import { snapshotFromRuntimeMemberStatuses, snapshotToMemberSpawnStatuses, } from './TeamLaunchStateEvaluator'; -import { writeTeamLaunchFailureArtifactPack } from './TeamLaunchFailureArtifactPack'; import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; @@ -5406,7 +5406,7 @@ interface LiveInboxRelayResult { interface OpenCodeMemberInboxRelayOptions { onlyMessageId?: string; - source?: 'watcher' | 'ui-send' | 'manual' | 'watchdog'; + source?: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup'; deliveryMetadata?: { replyRecipient?: string; actionMode?: AgentActionMode; @@ -6967,7 +6967,10 @@ export class TeamProvisioningService { const normalized = this.normalizeOpenCodeObservedToolName(toolName); if ( ledgerRecord?.messageKind === 'member_work_sync_nudge' && - normalized === 'member_work_sync_report' + (normalized === 'member_work_sync_report' || + normalized === 'review_start' || + normalized === 'review_approve' || + normalized === 'review_request_changes') ) { return true; } @@ -7195,6 +7198,7 @@ export class TeamProvisioningService { inboxMessageId: record.inboxMessageId, replyRecipient: record.replyRecipient, messageKind: record.messageKind, + workSyncIntent: record.workSyncIntent, actionMode: record.actionMode, taskRefs: record.taskRefs, status: record.status, @@ -7875,6 +7879,8 @@ export class TeamProvisioningService { replyRecipient?: string | null; actionMode?: AgentActionMode; messageKind?: OpenCodeTeamRuntimeMessageInput['messageKind']; + workSyncIntent?: OpenCodeTeamRuntimeMessageInput['workSyncIntent']; + workSyncReviewRequestEventIds?: string[]; taskRefs?: TaskRef[]; promptAccepted: boolean; visibleReply?: OpenCodeVisibleReplyProof | null; @@ -7922,6 +7928,8 @@ export class TeamProvisioningService { replyRecipient: input.replyRecipient ?? undefined, actionMode: input.actionMode, messageKind: input.messageKind, + workSyncIntent: input.workSyncIntent, + workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds, taskRefs: input.taskRefs, prePromptCursor: ledgerRecord.prePromptCursor, }); @@ -8424,34 +8432,43 @@ export class TeamProvisioningService { }> { const memberKey = record.memberName.trim().toLowerCase(); let recordsForMember: OpenCodePromptDeliveryLedgerRecord[] = [record]; + let ledgerReadSucceeded = false; try { const laneRecords = await this.createOpenCodePromptDeliveryLedger( record.teamName, record.laneId ).list(); + ledgerReadSucceeded = true; recordsForMember = laneRecords.filter( (candidate) => candidate.memberName.trim().toLowerCase() === memberKey ); } catch { recordsForMember = [record]; } - const latestRecord = recordsForMember.find((candidate) => candidate.id === record.id) ?? record; + const latestRecord = recordsForMember.find((candidate) => candidate.id === record.id) ?? null; + if (!latestRecord && ledgerReadSucceeded) { + return { + record, + decision: { action: 'suppress' }, + }; + } + const recordForDecision = latestRecord ?? record; const recordsByMember = new Map([ - [memberKey, recordsForMember.length > 0 ? recordsForMember : [latestRecord]], + [memberKey, recordsForMember.length > 0 ? recordsForMember : [recordForDecision]], ]); const activeMemberKeys = new Set([memberKey]); const proofIndex = await this.openCodeRuntimeDeliveryProofReader .readProofIndex({ - teamName: latestRecord.teamName, + teamName: recordForDecision.teamName, activeMemberKeys, recordsByMember, }) .catch(() => null); return { - record: latestRecord, + record: recordForDecision, decision: decideOpenCodeRuntimeDeliveryAdvisory({ - record: latestRecord, - proof: proofIndex?.getSnapshot(latestRecord.memberName, latestRecord), + record: recordForDecision, + proof: proofIndex?.getSnapshot(recordForDecision.memberName, recordForDecision), }), }; } @@ -8592,13 +8609,14 @@ export class TeamProvisioningService { selectOpenCodeRuntimeDeliveryReason(record) ?? record.responseState ?? record.status; + const action = decision ? `${decision.action}:${decision.severity ?? 'none'}` : 'record:none'; const normalized = reason .toLowerCase() .replace(/https?:\/\/\S+/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 96); - return normalized || 'unknown'; + return `${action}:${normalized || 'unknown'}`; } private scheduleOpenCodeRuntimeDeliveryAdvisoryReview( @@ -8773,6 +8791,7 @@ export class TeamProvisioningService { replyRecipient, actionMode: message.actionMode ?? null, messageKind: message.messageKind ?? null, + workSyncIntent: message.workSyncIntent ?? null, taskRefs: message.taskRefs ?? [], payloadHash: hashOpenCodePromptDeliveryPayload({ text: message.text, @@ -8817,6 +8836,8 @@ export class TeamProvisioningService { replyRecipient?: string; actionMode?: AgentActionMode; messageKind?: InboxMessage['messageKind']; + workSyncIntent?: InboxMessage['workSyncIntent']; + workSyncReviewRequestEventIds?: string[]; taskRefs?: TaskRef[]; attachments?: AttachmentPayload[]; source?: OpenCodeMemberInboxRelayOptions['source']; @@ -9042,6 +9063,8 @@ export class TeamProvisioningService { replyRecipient: input.replyRecipient, actionMode: input.actionMode, messageKind: input.messageKind, + workSyncIntent: input.workSyncIntent, + workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds, taskRefs: input.taskRefs, }); await this.rememberOpenCodeRuntimePidFromBridge({ @@ -9149,6 +9172,7 @@ export class TeamProvisioningService { replyRecipient: input.replyRecipient ?? 'user', actionMode: input.actionMode ?? null, messageKind: input.messageKind ?? null, + workSyncIntent: input.workSyncIntent ?? null, taskRefs: input.taskRefs ?? [], payloadHash: hashOpenCodePromptDeliveryPayload({ text: input.text, @@ -9288,6 +9312,8 @@ export class TeamProvisioningService { replyRecipient: input.replyRecipient, actionMode: input.actionMode, messageKind: input.messageKind, + workSyncIntent: input.workSyncIntent, + workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds, taskRefs: input.taskRefs, prePromptCursor: ledgerRecord.prePromptCursor, }); @@ -9439,6 +9465,8 @@ export class TeamProvisioningService { replyRecipient: input.replyRecipient, actionMode: input.actionMode, messageKind: input.messageKind, + workSyncIntent: input.workSyncIntent, + workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds, taskRefs: input.taskRefs, }); await this.rememberOpenCodeRuntimePidFromBridge({ @@ -9497,6 +9525,8 @@ export class TeamProvisioningService { replyRecipient: input.replyRecipient, actionMode: input.actionMode, messageKind: input.messageKind, + workSyncIntent: input.workSyncIntent, + workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds, taskRefs: input.taskRefs, promptAccepted, visibleReply: proof.visibleReply, @@ -11019,6 +11049,9 @@ export class TeamProvisioningService { toolSummary: message.toolSummary, toolCalls: message.toolCalls, messageKind: message.messageKind, + workSyncIntent: message.workSyncIntent, + workSyncIntentKey: message.workSyncIntentKey, + workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds, slashCommand: message.slashCommand, commandOutput: message.commandOutput, }); @@ -11050,6 +11083,9 @@ export class TeamProvisioningService { toolSummary: message.toolSummary, toolCalls: message.toolCalls, messageKind: message.messageKind, + workSyncIntent: message.workSyncIntent, + workSyncIntentKey: message.workSyncIntentKey, + workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds, slashCommand: message.slashCommand, commandOutput: message.commandOutput, }); @@ -21278,6 +21314,7 @@ export class TeamProvisioningService { replyRecipient: effectiveReplyRecipient, actionMode: effectiveActionMode ?? null, messageKind: message.messageKind ?? null, + workSyncIntent: message.workSyncIntent ?? null, taskRefs: effectiveTaskRefs, payloadHash: hashOpenCodePromptDeliveryPayload({ text: message.text, @@ -21332,6 +21369,8 @@ export class TeamProvisioningService { replyRecipient: effectiveReplyRecipient, actionMode: effectiveActionMode ?? undefined, messageKind: message.messageKind, + workSyncIntent: message.workSyncIntent, + workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds, taskRefs: effectiveTaskRefs, attachments: attachmentPayloads.attachments, source: effectiveSource, diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 1706d190..0208ba5a 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -6,7 +6,7 @@ import type { OpenCodeDeliveryResponseState, OpenCodeDeliveryVisibleReplyCorrelation, } from '../bridge/OpenCodeBridgeCommandContract'; -import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team'; +import type { AgentActionMode, InboxMessage, InboxMessageKind, TaskRef } from '@shared/types/team'; export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1; export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; @@ -31,8 +31,9 @@ export interface OpenCodePromptDeliveryLedgerRecord { runtimeSessionId: string | null; inboxMessageId: string; inboxTimestamp: string; - source: 'watcher' | 'ui-send' | 'manual' | 'watchdog'; + source: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup'; messageKind: InboxMessageKind | null; + workSyncIntent?: InboxMessage['workSyncIntent'] | null; replyRecipient: string; actionMode: AgentActionMode | null; taskRefs: TaskRef[]; @@ -99,6 +100,7 @@ const OPENCODE_PROMPT_DELIVERY_SOURCES = new Set (record.id === existing.id ? updated : record)); + } + if (existing.workSyncIntent == null && input.workSyncIntent) { + const updated: OpenCodePromptDeliveryLedgerRecord = { + ...existing, + workSyncIntent: input.workSyncIntent, updatedAt: input.now, }; result = updated; @@ -201,6 +214,7 @@ export class OpenCodePromptDeliveryLedgerStore { inboxTimestamp: input.inboxTimestamp, source: input.source, messageKind: input.messageKind ?? null, + workSyncIntent: input.workSyncIntent ?? null, replyRecipient: input.replyRecipient, actionMode: input.actionMode ?? null, taskRefs: input.taskRefs ?? [], diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts index a3676293..08bfb492 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts @@ -1,6 +1,6 @@ import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract'; import type { OpenCodePromptDeliveryStatus } from './OpenCodePromptDeliveryLedger'; -import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team'; +import type { AgentActionMode, InboxMessage, InboxMessageKind, TaskRef } from '@shared/types/team'; export type OpenCodePromptDeliveryRepairKind = | 'none' @@ -26,6 +26,7 @@ export interface OpenCodePromptDeliveryRepairInput { inboxMessageId: string; replyRecipient: string; messageKind: InboxMessageKind | null; + workSyncIntent?: InboxMessage['workSyncIntent'] | null; actionMode: AgentActionMode | null; taskRefs: TaskRef[]; status: OpenCodePromptDeliveryStatus; @@ -52,6 +53,12 @@ const SIDE_EFFECT_TOOL_NAMES = new Set([ 'multi_edit', ]); +const REVIEW_WORKFLOW_TOOL_NAMES = new Set([ + 'review_start', + 'review_approve', + 'review_request_changes', +]); + function none(reason: string): OpenCodePromptDeliveryRepairDecision { return { kind: 'none', retryable: false, controlText: null, reason }; } @@ -96,7 +103,11 @@ function hasTool(tools: Set, toolName: string): boolean { function hasTaskTool(tools: Set): boolean { for (const tool of tools) { - if (tool.startsWith('task_') || tool === 'runtime_task_event') { + if ( + tool.startsWith('task_') || + REVIEW_WORKFLOW_TOOL_NAMES.has(tool) || + tool === 'runtime_task_event' + ) { return true; } } @@ -137,6 +148,16 @@ function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): stri function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] { const taskIds = taskIdList(input.taskRefs); + if (input.workSyncIntent === 'review_pickup') { + return [ + 'This is a targeted member-work-sync review pickup control message. A plain acknowledgement is not sufficient proof.', + 'Open the current task, verify reviewState/status, then start or continue the review only if it is still assigned to you.', + 'Do not mark the review complete from this retry text alone.', + `If you cannot pick up the review now, call agent-teams_member_work_sync_status or mcp__agent-teams__member_work_sync_status with teamName="${input.teamName}" and memberName="${input.memberName}", then report state "blocked" or "still_working" only for the real current state.`, + taskIds ? `Relevant taskIds: ${taskIds}.` : null, + 'Do not invent or reuse a raw report token from this retry text.', + ].filter((line): line is string => line !== null); + } 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}".`, @@ -163,7 +184,9 @@ function noAssistantControlLines(input: OpenCodePromptDeliveryRepairInput): stri '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.' + ? input.workSyncIntent === 'review_pickup' + ? 'Follow the member-work-sync review pickup instructions for this message.' + : '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.`, ]; } diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index f9e68ecd..2f32bc79 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -28,6 +28,7 @@ import type { } from './TeamRuntimeAdapter'; import type { AgentActionMode, + InboxMessage, InboxMessageKind, OpenCodeAppManagedBootstrapCandidate, TaskRef, @@ -65,6 +66,8 @@ export interface OpenCodeTeamRuntimeMessageInput { replyRecipient?: string; actionMode?: AgentActionMode; messageKind?: InboxMessageKind; + workSyncIntent?: InboxMessage['workSyncIntent']; + workSyncReviewRequestEventIds?: string[]; taskRefs?: TaskRef[]; bootstrapCheckinRetry?: { runtimeSessionId: string; @@ -900,10 +903,15 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) memberName: input.memberName, inboundMessageId: input.messageId, ...(input.messageKind ? { messageKind: input.messageKind } : {}), + ...(input.workSyncIntent ? { workSyncIntent: input.workSyncIntent } : {}), + ...(input.workSyncReviewRequestEventIds?.length + ? { workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds } + : {}), taskRefs: input.taskRefs, }) : null; const isWorkSyncNudge = input.messageKind === 'member_work_sync_nudge'; + const isReviewPickupNudge = isWorkSyncNudge && input.workSyncIntent === 'review_pickup'; const taskIds = input.taskRefs ?.map((ref) => ref.taskId?.trim()) @@ -912,33 +920,44 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) // message_send reply here causes false delivery failures, so accept the // dedicated member_work_sync_report proof path while keeping normal user // messages on the visible reply contract. - const responseInstructions = isWorkSyncNudge + const responseInstructions = isReviewPickupNudge ? [ - 'This delivered app message is a member-work-sync nudge.', - 'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response 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) with teamName="${input.teamName}", memberName="${input.memberName}", the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`, - taskIds.length - ? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` - : null, + 'This delivered app message is a targeted member-work-sync review pickup nudge.', + 'Process the current review request now if it is still assigned to you. Open the task, verify reviewState/status, then use the review workflow tools to start or continue the review.', + 'Do not mark the review complete from this prompt alone.', + 'A visible agent-teams_message_send reply is optional. Concrete review progress, review tool usage, or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', + `If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with teamName="${input.teamName}" and memberName="${input.memberName}", then report state "blocked" or "still_working" only for the real current state.`, + taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null, `Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`, 'Do not reply only with acknowledgement.', ] - : [ - 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).', - `Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`, - 'Include source="runtime_delivery" in that message_send call.', - input.messageId - ? `Include relayOfMessageId="${input.messageId}" in that message_send call.` - : null, - input.taskRefs?.length - ? `If taskRefs are present in , include taskRefs exactly as provided in that message_send call: ${JSON.stringify(input.taskRefs)}.` - : null, - 'If message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.', - 'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.', - 'You must not end this turn empty.', - 'Do not answer only with plain assistant text when agent-teams_message_send is available.', - ]; + : isWorkSyncNudge + ? [ + 'This delivered app message is a member-work-sync nudge.', + 'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response 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) with teamName="${input.teamName}", memberName="${input.memberName}", the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`, + taskIds.length + ? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` + : null, + `Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`, + 'Do not reply only with acknowledgement.', + ] + : [ + 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).', + `Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`, + 'Include source="runtime_delivery" in that message_send call.', + input.messageId + ? `Include relayOfMessageId="${input.messageId}" in that message_send call.` + : null, + input.taskRefs?.length + ? `If taskRefs are present in , include taskRefs exactly as provided in that message_send call: ${JSON.stringify(input.taskRefs)}.` + : null, + 'If message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.', + 'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.', + 'You must not end this turn empty.', + 'Do not answer only with plain assistant text when agent-teams_message_send is available.', + ]; return [ '', diff --git a/src/renderer/components/team/TeamChangesSection.tsx b/src/renderer/components/team/TeamChangesSection.tsx index 2dfe4727..05aec250 100644 --- a/src/renderer/components/team/TeamChangesSection.tsx +++ b/src/renderer/components/team/TeamChangesSection.tsx @@ -1,47 +1,30 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; -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 { 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 TeamChangeSummaryState, useTeamChangesSummaries } from './useTeamChangesSummaries'; import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types'; -const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000; - interface TeamChangesSectionProps { teamName: string; tasks: TeamTaskWithKanban[]; onViewChanges: (taskId: string, filePath?: string) => void; } -interface TeamChangeSummaryState { - taskId: string; - changeSet: TaskChangeSetV2 | null; - error?: string; -} - -interface TeamChangeStats { - eligibleCount: number; - requestedCount: number; - deferredCount: number; -} - -interface TeamChangesLoadOptions { - forceFresh?: boolean; - showSpinner?: boolean; - preserveOnError?: boolean; +interface RenderedTeamChangeSummary { + summary: TeamChangeSummaryState; + task: TeamTaskWithKanban; + visibleFiles: FileChangeSummary[]; + fileBudget: number; } function getTaskChangeContributors( @@ -71,7 +54,7 @@ function getTaskChangeContributors( function getVisibleFileName(file: FileChangeSummary): string { const value = file.relativePath || file.filePath; - return value.split('/').pop() ?? value; + return value.split(/[\\/]/).pop() ?? value; } function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined { @@ -86,31 +69,15 @@ export const TeamChangesSection = memo(function TeamChangesSection({ tasks, onViewChanges, }: TeamChangesSectionProps): React.JSX.Element { - const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence); - const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence); const [sectionOpen, setSectionOpen] = useState(false); - const [summariesByTaskId, setSummariesByTaskId] = useState< - Record - >({}); - const [stats, setStats] = useState({ - eligibleCount: 0, - requestedCount: 0, - deferredCount: 0, - }); - const [loading, setLoading] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const [queuedRefreshTick, setQueuedRefreshTick] = useState(0); - const hasLoadedRef = useRef(false); - const requestSeqRef = useRef(0); - const activeRequestSeqRef = useRef(null); - const queuedRefreshOptionsRef = useRef(null); - const sectionOpenRef = useRef(sectionOpen); - const unknownScanCursorRef = useRef(0); - const lastRequestedTasksFingerprintRef = useRef(null); - const tasksFingerprint = useMemo(() => buildTeamChangesTasksFingerprint(tasks), [tasks]); + const { summariesByTaskId, stats, loading, refreshing, error, refresh } = useTeamChangesSummaries( + { + teamName, + tasks, + sectionOpen, + } + ); const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); - sectionOpenRef.current = sectionOpen; const visibleSummaries = useMemo(() => { return Object.values(summariesByTaskId) @@ -131,195 +98,18 @@ export const TeamChangesSection = memo(function TeamChangesSection({ ); const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS); const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined; - - const loadSummaries = useCallback( - async ({ - forceFresh = false, - showSpinner = false, - preserveOnError = true, - }: TeamChangesLoadOptions = {}): Promise => { - if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.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; - if (activeRequestSeqRef.current === null && sectionOpenRef.current) { - setQueuedRefreshTick((value) => value + 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, - deferredCount: plan.deferredCount, - }); - setError(null); - - if (plan.requests.length === 0) { - setSummariesByTaskId({}); - setLoading(false); - setRefreshing(false); - return; - } - - if (showSpinner) { - setLoading(true); - } else { - setRefreshing(true); - } - activeRequestSeqRef.current = requestSeq; - - try { - const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests); - if (requestSeqRef.current !== requestSeq) { - return; - } - - const currentTaskIds = new Set(tasks.map((task) => task.id)); - for (const item of response.items) { - const changeSet = item.changeSet; - const options = plan.requestOptionsByTaskId.get(item.taskId); - if (!changeSet || !options) continue; - - const nextPresence = resolveTaskChangePresenceFromResult(changeSet); - recordTaskChangePresence(teamName, item.taskId, options, nextPresence); - setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown'); - } - - setSummariesByTaskId((previous) => { - const next: Record = {}; - for (const [taskId, summary] of Object.entries(previous)) { - if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) { - next[taskId] = summary; - } - } - for (const item of response.items) { - const options = plan.requestOptionsByTaskId.get(item.taskId); - if (!options) continue; - next[item.taskId] = { - taskId: item.taskId, - changeSet: item.changeSet, - error: item.error, - }; - } - return next; - }); - } catch (err) { - if (requestSeqRef.current !== requestSeq) { - return; - } - if (!preserveOnError) { - setSummariesByTaskId({}); - } - setError(err instanceof Error ? err.message : 'Failed to load team changes'); - } finally { - 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); - } - } - }, - [recordTaskChangePresence, setSelectedTeamTaskChangePresence, tasks, teamName] - ); - - useEffect(() => { - hasLoadedRef.current = false; - requestSeqRef.current += 1; - activeRequestSeqRef.current = null; - queuedRefreshOptionsRef.current = null; - unknownScanCursorRef.current = 0; - lastRequestedTasksFingerprintRef.current = null; - setSummariesByTaskId({}); - setError(null); - 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); + const renderedSummaries = useMemo(() => { + const entries: RenderedTeamChangeSummary[] = []; + let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS; + for (const entry of visibleSummaries) { + const files = entry.summary.changeSet?.files ?? []; + const fileBudget = Math.max(0, remainingFileRows); + const visibleFiles = files.slice(0, fileBudget); + entries.push({ ...entry, visibleFiles, fileBudget }); + remainingFileRows -= visibleFiles.length; } - }, [sectionOpen]); - - useEffect(() => { - if (!sectionOpen || hasLoadedRef.current) { - return; - } - hasLoadedRef.current = true; - lastRequestedTasksFingerprintRef.current = tasksFingerprint; - void loadSummaries({ showSpinner: true, preserveOnError: false }); - }, [loadSummaries, sectionOpen, tasksFingerprint]); - - useEffect(() => { - if (!sectionOpen || !hasLoadedRef.current) { - return; - } - if (lastRequestedTasksFingerprintRef.current === tasksFingerprint) { - return; - } - lastRequestedTasksFingerprintRef.current = tasksFingerprint; - 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); - - return () => { - window.clearInterval(timer); - }; - }, [loadSummaries, sectionOpen]); - - const handleRefresh = useCallback(() => { - void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false }); - }, [loadSummaries]); - - let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS; + return entries; + }, [visibleSummaries]); return ( { event.stopPropagation(); - handleRefresh(); + refresh(); }} disabled={loading || refreshing} aria-label="Refresh team changes" @@ -370,12 +160,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({ ) : visibleSummaries.length > 0 ? (
- {visibleSummaries.map(({ summary, task }) => { + {renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => { const changeSet = summary.changeSet; const files = changeSet?.files ?? []; - const fileBudget = Math.max(0, remainingFileRows); - const visibleFiles = files.slice(0, fileBudget); - remainingFileRows -= visibleFiles.length; const contributors = getTaskChangeContributors(task, changeSet); const contributorLabel = contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned'; diff --git a/src/renderer/components/team/__tests__/teamChangesRequestPlan.test.ts b/src/renderer/components/team/__tests__/teamChangesRequestPlan.test.ts index 52b1f7f5..64c8794f 100644 --- a/src/renderer/components/team/__tests__/teamChangesRequestPlan.test.ts +++ b/src/renderer/components/team/__tests__/teamChangesRequestPlan.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildTeamChangeRequestPlan, buildTeamChangesTasksFingerprint, + TEAM_CHANGES_MAX_REQUESTS, TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, } from '../teamChangesRequestPlan'; @@ -49,6 +50,41 @@ describe('buildTeamChangeRequestPlan', () => { expect(plan.eligibleTaskIds.has('known-changed')).toBe(true); }); + it('skips deleted and duplicate tasks before counting candidates', () => { + const plan = buildTeamChangeRequestPlan( + [ + task({ id: 'changed', status: 'completed', changePresence: 'has_changes' }), + task({ id: 'changed', status: 'completed', changePresence: 'has_changes' }), + task({ id: 'deleted', status: 'deleted', changePresence: 'has_changes' }), + ], + 0, + false + ); + + expect(plan.requests.map((request) => request.taskId)).toEqual(['changed']); + expect(plan.eligibleCount).toBe(1); + expect(plan.deferredCount).toBe(0); + }); + + it('caps selected requests and reports deferred candidates', () => { + const plan = buildTeamChangeRequestPlan( + Array.from({ length: TEAM_CHANGES_MAX_REQUESTS + 5 }, (_, index) => + task({ + id: `changed-${index}`, + status: 'completed', + changePresence: 'has_changes', + updatedAt: `2026-05-09T08:${String(index).padStart(2, '0')}:00.000Z`, + }) + ), + 0, + false + ); + + expect(plan.requests).toHaveLength(TEAM_CHANGES_MAX_REQUESTS); + expect(plan.eligibleCount).toBe(TEAM_CHANGES_MAX_REQUESTS + 5); + expect(plan.deferredCount).toBe(5); + }); + it('rotates unknown scans and preserves summary-only request options', () => { const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) => task({ diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 88ea4296..26186238 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -72,6 +72,7 @@ import { import { format, formatDistanceToNow } from 'date-fns'; import { AlignLeft, + AlertTriangle, ArrowLeftFromLine, ArrowRightFromLine, Check, @@ -166,6 +167,7 @@ export const TaskDetailDialog = ({ const [taskLogStreamCount, setTaskLogStreamCount] = useState(undefined); const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [taskChangesFiles, setTaskChangesFiles] = useState(null); + const [taskChangesWarnings, setTaskChangesWarnings] = useState([]); const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesError, setTaskChangesError] = useState(null); const loadedTaskChangeSummaryKeyRef = useRef(null); @@ -235,6 +237,7 @@ export const TaskDetailDialog = ({ useEffect(() => { setChangesSectionOpen(false); setTaskChangesFiles(null); + setTaskChangesWarnings([]); setTaskChangesLoading(false); setTaskChangesError(null); setLogsRefreshing(false); @@ -392,6 +395,7 @@ export const TaskDetailDialog = ({ const syncTaskChangeSummaryResult = useCallback( (data: TaskChangeSetV2 | null) => { setTaskChangesFiles(data?.files ?? null); + setTaskChangesWarnings(data?.warnings ?? []); const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; if (currentTask && taskChangeRequestOptions) { recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence); @@ -441,6 +445,7 @@ export const TaskDetailDialog = ({ } if (!preserveFilesOnError) { setTaskChangesFiles(null); + setTaskChangesWarnings([]); } setTaskChangesError( error instanceof Error ? error.message : 'Failed to load task changes summary' @@ -583,6 +588,14 @@ export const TaskDetailDialog = ({ setChangesSectionOpen(isOpen); }, []); + const taskChangesBadge = !taskChangesLoading + ? taskChangesFiles && taskChangesFiles.length > 0 + ? taskChangesFiles.length + : taskChangesFiles && taskChangesWarnings.length > 0 + ? 'attention' + : undefined + : undefined; + const [taskDurationNowMs, setTaskDurationNowMs] = useState(() => Date.now()); const taskImplementationDuration = useMemo( () => calculateTaskImplementationDuration(currentTask, taskDurationNowMs), @@ -1186,9 +1199,7 @@ export const TaskDetailDialog = ({ key={`task-changes:${currentTask.id}`} title="Changes" icon={} - badge={ - !taskChangesLoading && taskChangesFiles ? taskChangesFiles.length : undefined - } + badge={taskChangesBadge} headerExtra={ taskChangesLoading && !changesSectionOpen ? ( ) : taskChangesError ? (

{taskChangesError}

- ) : taskChangesFiles && taskChangesFiles.length > 0 ? ( -
- {taskChangesFiles.map((file) => ( -
- - {onViewChanges ? ( - - ) : ( - - {file.relativePath} - - )} - - {file.linesAdded > 0 ? ( - +{file.linesAdded} - ) : null} - {file.linesRemoved > 0 ? ( - -{file.linesRemoved} - ) : null} - - - {onViewChanges ? ( - - - - - Review diff - - ) : null} - {onOpenInEditor ? ( - - - - - Open in editor - - ) : null} - + + {warning} +
+ ))} + {taskChangesWarnings.length > 2 ? ( +

+ {taskChangesWarnings.length - 2} more warnings +

+ ) : null}
- ))} + ) : null} + + {taskChangesFiles.length > 0 ? ( +
+ {taskChangesFiles.map((file) => ( +
+ + {onViewChanges ? ( + + ) : ( + + {file.relativePath} + + )} + + {file.linesAdded > 0 ? ( + +{file.linesAdded} + ) : null} + {file.linesRemoved > 0 ? ( + -{file.linesRemoved} + ) : null} + + + {onViewChanges ? ( + + + + + Review diff + + ) : null} + {onOpenInEditor ? ( + + + + + Open in editor + + ) : null} + +
+ ))} +
+ ) : changesSectionOpen ? ( +

+ {taskChangesWarnings.length > 0 + ? 'No reviewable file changes recovered' + : 'No file changes recorded'} +

+ ) : null}
) : changesSectionOpen ? (

No file changes recorded

diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 5a90d2ae..7d8de7ec 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -195,7 +195,7 @@ describe('KanbanTaskCard change badge', () => { }); }); - it('does not render the Changes action when changePresence needs attention', async () => { + it('renders a Changes attention action when changePresence needs attention', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -224,7 +224,7 @@ describe('KanbanTaskCard change badge', () => { await Promise.resolve(); }); - expect(host.querySelector('[aria-label="Changes"]')).toBeNull(); + expect(host.querySelector('[aria-label="Changes need attention"]')).not.toBeNull(); await act(async () => { root.unmount(); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 1600058b..f35445cb 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -263,14 +263,22 @@ export const KanbanTaskCard = memo( const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim(); const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0; + const canOpenChanges = + canDisplay && + (task.changePresence === 'has_changes' || task.changePresence === 'needs_attention'); + const changesNeedAttention = task.changePresence === 'needs_attention'; const metaActions = ( <> - {canDisplay && task.changePresence === 'has_changes' ? ( + {canOpenChanges ? ( } variant="ghost" - className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" + className={ + changesNeedAttention + ? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300' + : 'text-sky-400 hover:bg-sky-500/10 hover:text-sky-300' + } onClick={(e) => { e.stopPropagation(); onViewChanges!(task.id); diff --git a/src/renderer/components/team/useTeamChangesSummaries.ts b/src/renderer/components/team/useTeamChangesSummaries.ts new file mode 100644 index 00000000..3e008ba5 --- /dev/null +++ b/src/renderer/components/team/useTeamChangesSummaries.ts @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; + +import { + buildTeamChangeRequestPlan, + buildTeamChangesTasksFingerprint, +} from './teamChangesRequestPlan'; + +import type { TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types'; + +const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000; + +export interface TeamChangeSummaryState { + taskId: string; + changeSet: TaskChangeSetV2 | null; + error?: string; +} + +export interface TeamChangeStats { + eligibleCount: number; + requestedCount: number; + deferredCount: number; +} + +interface TeamChangesLoadOptions { + forceFresh?: boolean; + showSpinner?: boolean; + preserveOnError?: boolean; +} + +interface UseTeamChangesSummariesInput { + teamName: string; + tasks: TeamTaskWithKanban[]; + sectionOpen: boolean; +} + +interface UseTeamChangesSummariesResult { + summariesByTaskId: Record; + stats: TeamChangeStats; + loading: boolean; + refreshing: boolean; + error: string | null; + refresh: () => void; +} + +export function useTeamChangesSummaries({ + teamName, + tasks, + sectionOpen, +}: UseTeamChangesSummariesInput): UseTeamChangesSummariesResult { + const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence); + const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence); + const [summariesByTaskId, setSummariesByTaskId] = useState< + Record + >({}); + const [stats, setStats] = useState({ + eligibleCount: 0, + requestedCount: 0, + deferredCount: 0, + }); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [queuedRefreshTick, setQueuedRefreshTick] = useState(0); + const hasLoadedRef = useRef(false); + const mountedRef = useRef(true); + const requestSeqRef = useRef(0); + const activeRequestSeqRef = useRef(null); + const queuedRefreshOptionsRef = useRef(null); + const sectionOpenRef = useRef(sectionOpen); + const unknownScanCursorRef = useRef(0); + const lastRequestedTasksFingerprintRef = useRef(null); + const tasksFingerprint = useMemo( + () => (sectionOpen ? buildTeamChangesTasksFingerprint(tasks) : ''), + [sectionOpen, tasks] + ); + sectionOpenRef.current = sectionOpen; + + useEffect(() => { + return () => { + mountedRef.current = false; + requestSeqRef.current += 1; + activeRequestSeqRef.current = null; + queuedRefreshOptionsRef.current = null; + }; + }, []); + + const loadSummaries = useCallback( + async ({ + forceFresh = false, + showSpinner = false, + preserveOnError = true, + }: TeamChangesLoadOptions = {}): Promise => { + if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.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; + if (activeRequestSeqRef.current === null && sectionOpenRef.current) { + setQueuedRefreshTick((value) => value + 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, + deferredCount: plan.deferredCount, + }); + setError(null); + + if (plan.requests.length === 0) { + setSummariesByTaskId({}); + setLoading(false); + setRefreshing(false); + return; + } + + if (showSpinner) { + setLoading(true); + } else { + setRefreshing(true); + } + activeRequestSeqRef.current = requestSeq; + + try { + const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests); + if (!mountedRef.current || requestSeqRef.current !== requestSeq) { + return; + } + + const currentTaskIds = new Set(tasks.map((task) => task.id)); + for (const item of response.items) { + const changeSet = item.changeSet; + const options = plan.requestOptionsByTaskId.get(item.taskId); + if (!changeSet || !options) continue; + + const nextPresence = resolveTaskChangePresenceFromResult(changeSet); + recordTaskChangePresence(teamName, item.taskId, options, nextPresence); + setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown'); + } + + setSummariesByTaskId((previous) => { + const next: Record = {}; + for (const [taskId, summary] of Object.entries(previous)) { + if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) { + next[taskId] = summary; + } + } + for (const item of response.items) { + const options = plan.requestOptionsByTaskId.get(item.taskId); + if (!options) continue; + next[item.taskId] = { + taskId: item.taskId, + changeSet: item.changeSet, + error: item.error, + }; + } + return next; + }); + } catch (err) { + if (!mountedRef.current || requestSeqRef.current !== requestSeq) { + return; + } + if (!preserveOnError) { + setSummariesByTaskId({}); + } + setError(err instanceof Error ? err.message : 'Failed to load team changes'); + } finally { + if (mountedRef.current) { + 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); + } + } + } + }, + [recordTaskChangePresence, setSelectedTeamTaskChangePresence, tasks, teamName] + ); + + useEffect(() => { + hasLoadedRef.current = false; + requestSeqRef.current += 1; + activeRequestSeqRef.current = null; + queuedRefreshOptionsRef.current = null; + unknownScanCursorRef.current = 0; + lastRequestedTasksFingerprintRef.current = null; + setSummariesByTaskId({}); + setError(null); + 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; + setSummariesByTaskId({}); + setError(null); + setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 }); + setLoading(false); + setRefreshing(false); + } + }, [sectionOpen]); + + useEffect(() => { + if (!sectionOpen || hasLoadedRef.current) { + return; + } + hasLoadedRef.current = true; + lastRequestedTasksFingerprintRef.current = tasksFingerprint; + void loadSummaries({ showSpinner: true, preserveOnError: false }); + }, [loadSummaries, sectionOpen, tasksFingerprint]); + + useEffect(() => { + if (!sectionOpen || !hasLoadedRef.current) { + return; + } + if (lastRequestedTasksFingerprintRef.current === tasksFingerprint) { + return; + } + lastRequestedTasksFingerprintRef.current = tasksFingerprint; + 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); + + return () => { + window.clearInterval(timer); + }; + }, [loadSummaries, sectionOpen]); + + const refresh = useCallback(() => { + void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false }); + }, [loadSummaries]); + + return { + summariesByTaskId, + stats, + loading, + refreshing, + error, + refresh, + }; +} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 146b4bcb..ecd1eb46 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -4581,8 +4581,11 @@ export const createTeamSlice: StateCreator = (set, ); if (!status) return; if (statusMessageId !== normalizedMessageId) { + const blockerUserVisibleState = status.userVisibleImpact?.state; const blockerStillChecking = - status.userVisibleImpact?.state === 'checking' || status.responsePending === true; + blockerUserVisibleState !== undefined + ? blockerUserVisibleState === 'checking' + : status.responsePending === true; if (!blockerStillChecking) { const ownStatus = await unwrapIpc('team:getOpenCodeRuntimeDeliveryStatus', () => api.teams.getOpenCodeRuntimeDeliveryStatus(teamName, normalizedMessageId) diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 55ada481..17a0eca0 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -173,6 +173,9 @@ export function shouldClearPendingReplyForOpenCodeRuntimeDelivery( return false; } const userVisibleState = runtimeDelivery.userVisibleImpact?.state; + if (userVisibleState === 'none') { + return true; + } if (userVisibleState === 'warning' || userVisibleState === 'error') { return true; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 3b29e5c5..6306d2f9 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -664,6 +664,12 @@ export interface InboxMessage { toolCalls?: ToolCallMeta[]; /** Renderer-friendly semantic kind. Defaults to "default" when absent. */ messageKind?: InboxMessageKind; + /** Structured member-work-sync intent for runtime delivery and audit. */ + workSyncIntent?: 'agenda_sync' | 'review_pickup'; + /** Stable intent key, e.g. one review request event or a small review-request group. */ + workSyncIntentKey?: string; + /** Concrete review_requested event IDs covered by this nudge. */ + workSyncReviewRequestEventIds?: string[]; /** Structured slash-command metadata for sent command rows. */ slashCommand?: SlashCommandMeta; /** Structured command-output metadata for session-derived result rows. */ @@ -708,6 +714,9 @@ export interface SendMessageRequest { toolSummary?: string; toolCalls?: ToolCallMeta[]; messageKind?: InboxMessageKind; + workSyncIntent?: InboxMessage['workSyncIntent']; + workSyncIntentKey?: string; + workSyncReviewRequestEventIds?: string[]; slashCommand?: SlashCommandMeta; commandOutput?: CommandOutputMeta; } diff --git a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts index c81270c3..be8fba02 100644 --- a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts +++ b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts @@ -245,7 +245,242 @@ describe('buildActionableWorkAgenda', () => { taskId: 'task-1', kind: 'review', assignee: 'alice', - evidence: { reviewer: 'alice' }, + evidence: { + reviewer: 'alice', + reviewObligation: 'review_pickup_required', + reviewRequestEventId: 'evt-1', + canBypassPhase2: true, + }, + }); + }); + + it('routes self-review to lead oversight instead of reviewer pickup', () => { + const ownerAgenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'team-lead', agentType: 'lead' }], + tasks: [ + { + id: 'task-self-review', + subject: 'Self review should be reassigned', + status: 'completed', + owner: 'alice', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-self-review', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + ], + }, + ], + hash, + }); + const leadAgenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'team-lead', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'team-lead', agentType: 'lead' }], + tasks: [ + { + id: 'task-self-review', + subject: 'Self review should be reassigned', + status: 'completed', + owner: 'alice', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-self-review', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(ownerAgenda.items).toEqual([]); + expect(leadAgenda.items).toHaveLength(1); + expect(leadAgenda.items[0]).toMatchObject({ + taskId: 'task-self-review', + kind: 'clarification', + assignee: 'team-lead', + reason: 'self_review_invalid', + evidence: { + owner: 'alice', + reviewer: 'alice', + reviewRequestEventId: 'evt-self-review', + reviewDiagnostics: ['self_review_invalid'], + }, + }); + }); + + it('does not treat an older review_started event as progress for a newer review request', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Review current request', + status: 'completed', + owner: 'bob', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-old-start', + type: 'review_started', + timestamp: '2026-04-29T00:00:00.000Z', + actor: 'alice', + }, + { + id: 'evt-old-approved', + type: 'review_approved', + timestamp: '2026-04-29T00:01:00.000Z', + actor: 'alice', + }, + { + id: 'evt-new-request', + type: 'review_requested', + timestamp: '2026-04-29T00:02:00.000Z', + reviewer: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items).toHaveLength(1); + expect(agenda.items[0]?.evidence).toMatchObject({ + reviewObligation: 'review_pickup_required', + reviewRequestEventId: 'evt-new-request', + canBypassPhase2: true, + historyEventIds: ['evt-new-request'], + }); + expect(agenda.items[0]?.evidence.reviewStartedEventId).toBeUndefined(); + }); + + it('routes a newer review request to the requested reviewer even when kanban reviewer is stale', () => { + const aliceAgenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + kanbanReviewersByTaskId: { 'task-1': 'alice' }, + tasks: [ + { + id: 'task-1', + subject: 'Reviewer changed', + status: 'completed', + owner: 'tom', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-old-request', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + { + id: 'evt-new-request', + type: 'review_requested', + timestamp: '2026-04-29T00:01:00.000Z', + reviewer: 'bob', + }, + ], + }, + ], + hash, + }); + const bobAgenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + kanbanReviewersByTaskId: { 'task-1': 'alice' }, + tasks: [ + { + id: 'task-1', + subject: 'Reviewer changed', + status: 'completed', + owner: 'tom', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-old-request', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + { + id: 'evt-new-request', + type: 'review_requested', + timestamp: '2026-04-29T00:01:00.000Z', + reviewer: 'bob', + }, + ], + }, + ], + hash, + }); + + expect(aliceAgenda.items).toEqual([]); + expect(bobAgenda.items).toHaveLength(1); + expect(bobAgenda.items[0]?.evidence).toMatchObject({ + reviewer: 'bob', + reviewObligation: 'review_pickup_required', + reviewRequestEventId: 'evt-new-request', + reviewDiagnostics: ['kanban_reviewer_differs_from_review_request'], + }); + }); + + it('marks a started review as in-progress evidence that cannot bypass phase2', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Review already started', + status: 'completed', + owner: 'bob', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-request', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + { + id: 'evt-start', + type: 'review_started', + timestamp: '2026-04-29T00:01:00.000Z', + actor: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items[0]?.evidence).toMatchObject({ + reviewObligation: 'review_in_progress', + reviewRequestEventId: 'evt-request', + reviewStartedEventId: 'evt-start', + reviewStartedBy: 'alice', + canBypassPhase2: false, + historyEventIds: ['evt-request', 'evt-start'], }); }); diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 0dc9137f..5374f1a9 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -10,6 +10,8 @@ import { type MemberWorkSyncAuditEvent, type MemberWorkSyncInboxNudgePort, type MemberWorkSyncOutboxStorePort, + type MemberWorkSyncReviewPickupDeliveryPort, + type MemberWorkSyncReviewPickupEscalationPort, type MemberWorkSyncStatusStorePort, type MemberWorkSyncUseCaseDeps, } from '@features/member-work-sync/core/application'; @@ -41,6 +43,40 @@ const workItem: MemberWorkSyncActionableWorkItem = { }, }; +const reviewPickupItem: MemberWorkSyncActionableWorkItem = { + taskId: 'task-review', + displayId: '22222222', + subject: 'Review docs', + kind: 'review', + assignee: 'bob', + priority: 'review_requested', + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'alice', + reviewer: 'bob', + reviewState: 'review', + reviewCycleId: 'evt-review-request', + reviewRequestEventId: 'evt-review-request', + reviewObligation: 'review_pickup_required', + canBypassPhase2: true, + historyEventIds: ['evt-review-request'], + }, +}; + +const secondReviewPickupItem: MemberWorkSyncActionableWorkItem = { + ...reviewPickupItem, + taskId: 'task-review-b', + displayId: '33333333', + subject: 'Review API', + evidence: { + ...reviewPickupItem.evidence, + reviewCycleId: 'evt-review-request-b', + reviewRequestEventId: 'evt-review-request-b', + historyEventIds: ['evt-review-request-b'], + }, +}; + class MutableClock { private current = new Date('2026-04-29T00:00:00.000Z'); @@ -185,6 +221,8 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { ...current, status: 'delivered', deliveredMessageId: input.deliveredMessageId, + ...(input.deliveryState ? { deliveryState: input.deliveryState } : {}), + ...(input.deliveryDiagnostics ? { deliveryDiagnostics: input.deliveryDiagnostics } : {}), updatedAt: input.nowIso, }); } @@ -218,6 +256,26 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { item.updatedAt >= input.sinceIso ).length; } + + async findDeliveredReviewPickupRequestEventIds(input: { + memberName: string; + reviewRequestEventIds: string[]; + }): Promise { + const requested = new Set(input.reviewRequestEventIds); + return [ + ...new Set( + [...this.items.values()] + .filter( + (item) => + item.memberName === input.memberName && + item.status === 'delivered' && + item.payload.workSyncIntent === 'review_pickup' + ) + .flatMap((item) => item.payload.workSyncReviewRequestEventIds ?? []) + .filter((eventId) => requested.has(eventId)) + ), + ].sort(); + } } class InMemoryInboxNudge implements MemberWorkSyncInboxNudgePort { @@ -242,6 +300,8 @@ function createDeps(options?: { outboxStore?: MemberWorkSyncOutboxStorePort; inboxNudge?: MemberWorkSyncInboxNudgePort; busySignal?: MemberWorkSyncUseCaseDeps['busySignal']; + reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort; + reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort; }) { const clock = new MutableClock(); const store = new InMemoryStatusStore(); @@ -272,6 +332,12 @@ function createDeps(options?: { ...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}), ...(options?.inboxNudge ? { inboxNudge: options.inboxNudge } : {}), ...(options?.busySignal ? { busySignal: options.busySignal } : {}), + ...(options?.reviewPickupDelivery + ? { reviewPickupDelivery: options.reviewPickupDelivery } + : {}), + ...(options?.reviewPickupEscalation + ? { reviewPickupEscalation: options.reviewPickupEscalation } + : {}), reportToken: { create: async (input) => ({ token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`, @@ -374,6 +440,26 @@ describe('MemberWorkSync use cases', () => { expect(result.status.report?.expiresAt).toBe('2026-04-29T00:02:00.000Z'); }); + it('uses a short still_working lease for review pickup reports', async () => { + const { deps } = createDeps({ items: [reviewPickupItem] }); + const reader = new MemberWorkSyncReconciler(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + const result = await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, + leaseTtlMs: 60 * 60 * 1000, + source: 'test', + }); + + expect(result.accepted).toBe(true); + expect(result.status.report?.expiresAt).toBe('2026-04-29T00:10:00.000Z'); + }); + it('rejects stale reports without turning app-side validation failures into pending intents', async () => { const { auditEvents, deps, store } = createDeps(); const result = await new MemberWorkSyncReporter(deps).execute({ @@ -473,6 +559,178 @@ describe('MemberWorkSync use cases', () => { expect(outbox.ensures).toEqual([]); }); + it('creates review pickup outbox while shadow data is collecting only with delivery capability', async () => { + const outbox = new InMemoryOutboxStore(); + const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = { + canDeliver: async () => ({ ok: true }), + deliver: async () => ({ + ok: true, + state: 'prompt_accepted', + messageId: 'unused', + }), + }; + const { auditEvents, deps } = createDeps({ + items: [reviewPickupItem], + providerId: 'opencode', + outboxStore: outbox, + reviewPickupDelivery, + }); + + const status = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(outbox.ensures).toHaveLength(1); + expect(outbox.ensures[0]).toMatchObject({ + id: 'member-work-sync:team-a:bob:review-pickup:evt-review-request', + agendaFingerprint: status.agenda.fingerprint, + payload: { + workSyncIntent: 'review_pickup', + workSyncIntentKey: 'review-pickup:evt-review-request', + workSyncReviewRequestEventIds: ['evt-review-request'], + }, + }); + }); + + it('creates one review pickup outbox for multiple current review requests', async () => { + const outbox = new InMemoryOutboxStore(); + const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = { + canDeliver: async () => ({ ok: true }), + deliver: async () => ({ + ok: true, + state: 'prompt_accepted', + messageId: 'unused', + }), + }; + const { deps } = createDeps({ + items: [reviewPickupItem, secondReviewPickupItem], + providerId: 'opencode', + outboxStore: outbox, + reviewPickupDelivery, + }); + + await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(outbox.ensures).toHaveLength(1); + expect(outbox.ensures[0]).toMatchObject({ + id: 'member-work-sync:team-a:bob:review-pickup:evt-review-request+evt-review-request-b', + payload: { + workSyncIntent: 'review_pickup', + workSyncIntentKey: 'review-pickup:evt-review-request+evt-review-request-b', + workSyncReviewRequestEventIds: ['evt-review-request', 'evt-review-request-b'], + taskRefs: [ + { taskId: 'task-review', displayId: '22222222', teamName: 'team-a' }, + { taskId: 'task-review-b', displayId: '33333333', teamName: 'team-a' }, + ], + }, + }); + }); + + it('filters already delivered review request ids before planning another pickup nudge', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = { + canDeliver: async () => ({ ok: true }), + deliver: async (input) => ({ + ok: true, + state: 'prompt_accepted', + messageId: input.messageId, + }), + }; + const { deps, source } = createDeps({ + items: [reviewPickupItem], + providerId: 'opencode', + outboxStore: outbox, + inboxNudge: inbox, + reviewPickupDelivery, + }); + const reconciler = new MemberWorkSyncReconciler(deps); + + await reconciler.execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + source.agenda.items = [reviewPickupItem, secondReviewPickupItem]; + await reconciler.execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(outbox.ensures.at(-1)).toMatchObject({ + id: 'member-work-sync:team-a:bob:review-pickup:evt-review-request-b', + payload: { + workSyncIntent: 'review_pickup', + workSyncReviewRequestEventIds: ['evt-review-request-b'], + taskRefs: [{ taskId: 'task-review-b', displayId: '33333333', teamName: 'team-a' }], + }, + }); + }); + + it('does not create review pickup outbox when delivery capability is unavailable', async () => { + const outbox = new InMemoryOutboxStore(); + const escalations: Array[0]> = + []; + const { auditEvents, deps } = createDeps({ + items: [reviewPickupItem], + providerId: 'codex', + outboxStore: outbox, + reviewPickupEscalation: { + escalate: async (input) => { + escalations.push(input); + }, + }, + }); + + await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(outbox.ensures).toEqual([]); + expect(auditEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'review_pickup_delivery_unavailable', + reason: 'review_pickup_delivery_port_unavailable', + }), + expect.objectContaining({ + event: 'review_pickup_escalated', + reason: 'review_pickup_delivery_port_unavailable', + }), + expect.objectContaining({ + event: 'nudge_skipped', + reason: 'review_pickup_delivery_unavailable', + }), + ]) + ); + expect(escalations).toEqual([ + expect.objectContaining({ + teamName: 'team-a', + memberName: 'bob', + reason: 'review_pickup_delivery_port_unavailable', + reviewRequestEventIds: ['evt-review-request'], + }), + ]); + }); + it('does not create outbox nudges from read-only diagnostics requests', async () => { const outbox = new InMemoryOutboxStore(); const { deps, store } = createDeps({ outboxStore: outbox }); @@ -559,6 +817,190 @@ describe('MemberWorkSync use cases', () => { }); }); + it('marks review pickup delivered only after the delivery port confirms prompt acceptance', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const deliveryCalls: Array[0]> = + []; + const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = { + canDeliver: async () => ({ ok: true }), + deliver: async (input) => { + deliveryCalls.push(input); + return { + ok: true, + state: 'prompt_accepted', + messageId: input.messageId, + diagnostics: ['accepted_by_bridge'], + }; + }, + }; + const { auditEvents, deps } = createDeps({ + items: [reviewPickupItem], + providerId: 'opencode', + outboxStore: outbox, + inboxNudge: inbox, + reviewPickupDelivery, + }); + + await new MemberWorkSyncReconciler(deps).execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 }); + expect(inbox.inserted).toHaveLength(1); + expect(deliveryCalls).toHaveLength(1); + expect(deliveryCalls[0]).toMatchObject({ + messageId: 'member-work-sync:team-a:bob:review-pickup:evt-review-request', + inserted: true, + providerId: 'opencode', + payload: { + workSyncIntent: 'review_pickup', + }, + }); + expect( + outbox.items.get('member-work-sync:team-a:bob:review-pickup:evt-review-request') + ).toMatchObject({ + status: 'delivered', + deliveryState: 'prompt_accepted', + deliveryDiagnostics: ['accepted_by_bridge'], + }); + }); + + it('marks review pickup terminal when delivery reports terminal failure', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const escalations: Array[0]> = + []; + const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = { + canDeliver: async () => ({ ok: true }), + deliver: async () => ({ + ok: false, + reason: 'terminal_failure', + message: 'empty_assistant_turn', + diagnostics: ['empty_assistant_turn'], + }), + }; + const { auditEvents, deps } = createDeps({ + items: [reviewPickupItem], + providerId: 'opencode', + outboxStore: outbox, + inboxNudge: inbox, + reviewPickupDelivery, + reviewPickupEscalation: { + escalate: async (input) => { + escalations.push(input); + }, + }, + }); + + await new MemberWorkSyncReconciler(deps).execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(summary).toMatchObject({ claimed: 1, delivered: 0, terminal: 1 }); + expect(inbox.inserted).toHaveLength(1); + const item = outbox.items.get('member-work-sync:team-a:bob:review-pickup:evt-review-request'); + expect(item).toMatchObject({ + status: 'failed_terminal', + lastError: 'empty_assistant_turn', + }); + expect(item?.nextAttemptAt).toBeUndefined(); + + await new MemberWorkSyncReconciler(deps).execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(auditEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'review_pickup_escalated', + reason: 'review_pickup_delivery_failed_still_stuck', + }), + ]) + ); + expect(escalations).toEqual([ + expect.objectContaining({ + reason: 'review_pickup_delivery_failed_still_stuck', + reviewRequestEventIds: ['evt-review-request'], + }), + ]); + }); + + it('escalates instead of sending another review pickup nudge when the same request is still stuck after delivery', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const escalations: Array[0]> = + []; + const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = { + canDeliver: async () => ({ ok: true }), + deliver: async (input) => ({ + ok: true, + state: 'prompt_accepted', + messageId: input.messageId, + }), + }; + const { auditEvents, deps } = createDeps({ + items: [reviewPickupItem], + providerId: 'opencode', + outboxStore: outbox, + inboxNudge: inbox, + reviewPickupDelivery, + reviewPickupEscalation: { + escalate: async (input) => { + escalations.push(input); + }, + }, + }); + + const reconciler = new MemberWorkSyncReconciler(deps); + await reconciler.execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + await reconciler.execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(inbox.inserted).toHaveLength(1); + expect( + outbox.items.get('member-work-sync:team-a:bob:review-pickup:evt-review-request') + ).toMatchObject({ status: 'delivered' }); + expect(auditEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'review_pickup_escalated', + reason: 'review_pickup_already_delivered_still_stuck', + }), + expect.objectContaining({ + event: 'nudge_skipped', + reason: 'review_pickup_already_delivered_still_stuck', + }), + ]) + ); + expect(escalations).toEqual([ + expect.objectContaining({ + reason: 'review_pickup_already_delivered_still_stuck', + reviewRequestEventIds: ['evt-review-request'], + }), + ]); + }); + it('recomputes agenda before dispatch and supersedes stale outbox fingerprints', async () => { const outbox = new InMemoryOutboxStore(); const inbox = new InMemoryInboxNudge(); diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts index 86a6d1a5..58994dc6 100644 --- a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { decideMemberWorkSyncNudgeActivation } from '@features/member-work-sync/core/application'; -import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '@features/member-work-sync/contracts'; +import type { + MemberWorkSyncStatus, + MemberWorkSyncTeamMetrics, +} from '@features/member-work-sync/contracts'; function status(overrides: Partial = {}): MemberWorkSyncStatus { return { @@ -102,6 +105,164 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => { ).toEqual({ active: false, reason: 'phase2_not_ready' }); }); + it('allows strict review pickup nudges through phase2 collection before delivery capability is checked', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status({ + providerId: 'anthropic', + agenda: { + ...status().agenda, + items: [ + { + taskId: 'task-review', + displayId: '#2', + subject: 'Review current request', + kind: 'review', + assignee: 'alice', + priority: 'review_requested', + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'bob', + reviewer: 'alice', + reviewState: 'review', + reviewCycleId: 'evt-review-request', + reviewRequestEventId: 'evt-review-request', + reviewObligation: 'review_pickup_required', + canBypassPhase2: true, + historyEventIds: ['evt-review-request'], + }, + }, + ], + }, + }), + metrics: metrics(), + }) + ).toEqual({ active: true, reason: 'review_pickup_required' }); + }); + + it('does not bypass phase2 for review pickup when shadow would not nudge', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status({ + providerId: 'anthropic', + shadow: { + reconciledBy: 'queue', + wouldNudge: false, + fingerprintChanged: false, + }, + agenda: { + ...status().agenda, + items: [ + { + taskId: 'task-review', + displayId: '#2', + subject: 'Review current request', + kind: 'review', + assignee: 'alice', + priority: 'review_requested', + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'bob', + reviewer: 'alice', + reviewState: 'review', + reviewCycleId: 'evt-review-request', + reviewRequestEventId: 'evt-review-request', + reviewObligation: 'review_pickup_required', + canBypassPhase2: true, + historyEventIds: ['evt-review-request'], + }, + }, + ], + }, + }), + metrics: metrics(), + }) + ).toEqual({ active: false, reason: 'phase2_not_ready' }); + }); + + it('does not bypass phase2 for ambiguous review pickup evidence', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: status({ + agenda: { + ...status().agenda, + items: [ + { + taskId: 'task-review', + displayId: '#2', + subject: 'Review current request', + kind: 'review', + assignee: 'alice', + priority: 'review_requested', + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'bob', + reviewer: 'alice', + reviewState: 'review', + reviewCycleId: 'kanban:alice', + reviewObligation: 'review_pickup_required', + canBypassPhase2: false, + reviewDiagnostics: ['review_request_event_id_missing'], + }, + }, + ], + }, + }), + metrics: metrics(), + }) + ).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' }); + }); + + it('allows multiple strict review pickup requests through the review pickup path', () => { + const reviewItem = { + taskId: 'task-review-a', + displayId: '#2', + subject: 'Review current request', + kind: 'review' as const, + assignee: 'alice', + priority: 'review_requested' as const, + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'bob', + reviewer: 'alice', + reviewState: 'review', + reviewCycleId: 'evt-review-request-a', + reviewRequestEventId: 'evt-review-request-a', + reviewObligation: 'review_pickup_required' as const, + canBypassPhase2: true, + historyEventIds: ['evt-review-request-a'], + }, + }; + + expect( + decideMemberWorkSyncNudgeActivation({ + status: status({ + agenda: { + ...status().agenda, + items: [ + reviewItem, + { + ...reviewItem, + taskId: 'task-review-b', + evidence: { + ...reviewItem.evidence, + reviewCycleId: 'evt-review-request-b', + reviewRequestEventId: 'evt-review-request-b', + historyEventIds: ['evt-review-request-b'], + }, + }, + ], + }, + }), + metrics: metrics(), + }) + ).toEqual({ active: true, reason: 'review_pickup_required' }); + }); + it('does not activate when blocking safety metrics are present', () => { expect( decideMemberWorkSyncNudgeActivation({ diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index 7062c135..4628d00a 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -46,13 +46,16 @@ function makeStatus(overrides: Partial): MemberWorkSyncSta }; } -function makeNudgePayload(overrides: Partial = {}): MemberWorkSyncNudgePayload { +function makeNudgePayload( + overrides: Partial = {} +): MemberWorkSyncNudgePayload { return { from: 'system', to: 'bob', messageKind: 'member_work_sync_nudge', source: 'member-work-sync', actionMode: 'do', + workSyncIntent: 'agenda_sync', text: 'Work sync check: continue the current task or report a blocker.', taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], ...overrides, @@ -129,7 +132,9 @@ describe('JsonMemberWorkSyncStore', () => { }); it('prefers member-scoped v2 status over legacy v1 status', async () => { - await store.write(makeStatus({ state: 'caught_up', agenda: { ...makeStatus({}).agenda, items: [] } })); + await store.write( + makeStatus({ state: 'caught_up', agenda: { ...makeStatus({}).agenda, items: [] } }) + ); const legacyStatusPath = join(root, 'team-a', '.member-work-sync', 'status.json'); await mkdir(join(root, 'team-a', '.member-work-sync'), { recursive: true }); @@ -252,9 +257,9 @@ describe('JsonMemberWorkSyncStore', () => { 'utf8' ) ); - expect(Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)).toEqual([ - 'tom', - ]); + expect( + Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName) + ).toEqual(['tom']); }); it('repairs a partially missing pending-report index route from member-scoped report files', async () => { @@ -491,7 +496,10 @@ describe('JsonMemberWorkSyncStore', () => { attemptGeneration: 2, }); const index = JSON.parse( - await readFile(join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), 'utf8') + await readFile( + join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), + 'utf8' + ) ); expect(index.items[input.id]).toMatchObject({ memberName: 'bob', @@ -635,6 +643,107 @@ describe('JsonMemberWorkSyncStore', () => { expect(repaired.items[bobInput.id]).toMatchObject({ memberName: 'bob', status: 'delivered' }); }); + it('finds delivered review pickup request event ids from member-scoped outbox files', async () => { + const input = { + id: 'member-work-sync:team-a:bob:review-pickup:evt-a+evt-b', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:review', + payloadHash: 'hash-review', + payload: makeNudgePayload({ + workSyncIntent: 'review_pickup', + workSyncIntentKey: 'review-pickup:evt-a+evt-b', + workSyncReviewRequestEventIds: ['evt-a', 'evt-b'], + }), + nowIso: '2026-04-29T00:00:00.000Z', + }; + await store.ensurePending(input); + const [claimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 1, + }); + await store.markDelivered({ + teamName: 'team-a', + id: input.id, + attemptGeneration: claimed.attemptGeneration, + deliveredMessageId: 'message-1', + deliveryState: 'prompt_accepted', + nowIso: '2026-04-29T00:02:00.000Z', + }); + + await expect( + store.findDeliveredReviewPickupRequestEventIds({ + teamName: 'team-a', + memberName: 'bob', + reviewRequestEventIds: ['evt-b', 'evt-c'], + }) + ).resolves.toEqual(['evt-b']); + }); + + it('revives a claimed review pickup outbox item when only the payload text changed', async () => { + const input = { + id: 'member-work-sync:team-a:bob:review-pickup:evt-a', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:review-a', + payloadHash: 'hash-review-a', + payload: makeNudgePayload({ + workSyncIntent: 'review_pickup', + workSyncIntentKey: 'review-pickup:evt-a', + workSyncReviewRequestEventIds: ['evt-a'], + text: 'Review pickup required: old subject', + }), + nowIso: '2026-04-29T00:00:00.000Z', + }; + await store.ensurePending(input); + const [claimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 1, + }); + expect(claimed.status).toBe('claimed'); + + const result = await store.ensurePending({ + ...input, + agendaFingerprint: 'agenda:v1:review-b', + payloadHash: 'hash-review-b', + payload: { + ...input.payload, + text: 'Review pickup required: renamed subject', + }, + nowIso: '2026-04-29T00:02:00.000Z', + }); + + expect(result).toMatchObject({ + ok: true, + outcome: 'existing', + item: { + status: 'pending', + agendaFingerprint: 'agenda:v1:review-b', + payloadHash: 'hash-review-b', + payload: { + workSyncIntent: 'review_pickup', + workSyncIntentKey: 'review-pickup:evt-a', + text: 'Review pickup required: renamed subject', + }, + }, + }); + const [reclaimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-b', + nowIso: '2026-04-29T00:03:00.000Z', + limit: 1, + }); + expect(reclaimed).toMatchObject({ + id: input.id, + payloadHash: 'hash-review-b', + payload: { text: 'Review pickup required: renamed subject' }, + }); + }); + it('repairs stale due outbox index routes before persisting claim results', async () => { const bobInput = { id: 'member-work-sync:team-a:bob:agenda:v1:abc', @@ -667,11 +776,14 @@ describe('JsonMemberWorkSyncStore', () => { }); expect(claimed.map((item) => item.memberName)).toEqual(['tom']); const repaired = JSON.parse( - await readFile(join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), 'utf8') + await readFile( + join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), + 'utf8' + ) ); - expect(Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)).toEqual([ - 'tom', - ]); + expect( + Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName) + ).toEqual(['tom']); }); it('repairs partially missing due outbox index routes before claiming', async () => { @@ -755,8 +867,9 @@ describe('JsonMemberWorkSyncStore', () => { }); expect(claimed).toHaveLength(1); expect( - JSON.parse(await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8')) - .items[input.id] + JSON.parse( + await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8') + ).items[input.id] ).toMatchObject({ status: 'claimed' }); expect(auditEvents.map((event) => `${event.event}:${event.reason}`)).toEqual( expect.arrayContaining([ diff --git a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts index 7ecc1179..4829aa86 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts @@ -215,4 +215,39 @@ describe('MemberWorkSyncTaskImpactResolver', () => { diagnostics: [], }); }); + + it('targets lead oversight when the changed task is a self-review', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-self-review', + subject: 'Self review', + status: 'completed', + owner: 'alice', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-self-review', + type: 'review_requested', + timestamp: '2026-05-06T19:00:00.000Z', + reviewer: 'alice', + }, + ], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead']), + }, + } as never); + + await expect( + resolver.resolve({ teamName: 'team-a', taskId: 'task-self-review' }) + ).resolves.toEqual({ + memberNames: ['alice', 'team-lead'], + fallbackTeamWide: false, + diagnostics: ['self_review_invalid'], + }); + }); }); diff --git a/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts b/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts index 7f51aeeb..f250ed4b 100644 --- a/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts +++ b/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts @@ -19,6 +19,7 @@ function makeInput(overrides: Partial = {}): NudgeInput { messageKind: 'member_work_sync_nudge', source: 'member-work-sync', actionMode: 'do', + workSyncIntent: 'agenda_sync', text: 'Please reconcile your current work state.', taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], }, @@ -73,6 +74,9 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => { summary: 'Work sync check', source: 'system_notification', messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + workSyncIntentKey: undefined, + workSyncReviewRequestEventIds: undefined, }); }); diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index f74d8760..9516ed14 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -299,7 +299,13 @@ async function forceRetryableOutboxDue(input: { expect(touched).toBeGreaterThan(0); await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8'); await fs.promises.rm( - path.join(input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'outbox-index.json'), + path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'outbox-index.json' + ), { force: true } ); } @@ -794,7 +800,7 @@ describe('createMemberWorkSyncFeature composition', () => { 'utf8' ); expect(journal).toContain('"event":"nudge_skipped"'); - expect(journal).toContain('"reason":"phase2_not_ready"'); + expect(journal).toContain('"reason":"blocking_metrics"'); expect(journal).not.toContain('"event":"nudge_delivered"'); } finally { await feature.dispose(); @@ -1098,7 +1104,7 @@ describe('createMemberWorkSyncFeature composition', () => { 'utf8' ); expect(journal).toContain('"event":"nudge_skipped"'); - expect(journal).toContain('"reason":"phase2_not_ready"'); + expect(journal).toContain('"reason":"blocking_metrics"'); }); await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); @@ -1301,7 +1307,14 @@ describe('createMemberWorkSyncFeature composition', () => { ).toHaveLength(2); const journal = await fs.promises.readFile( - path.join(teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl'), + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), 'utf8' ); const events = journal @@ -2422,10 +2435,7 @@ describe('createMemberWorkSyncFeature composition', () => { try { const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' }); expect(env).toEqual({ - [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( - root, - '.member-work-sync/runtime-hooks' - ), + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'), }); await expect( fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) @@ -2448,10 +2458,7 @@ describe('createMemberWorkSyncFeature composition', () => { try { const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }); expect(env).toEqual({ - [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( - root, - '.member-work-sync/runtime-hooks' - ), + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'), }); await expect( fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) @@ -2470,10 +2477,7 @@ describe('createMemberWorkSyncFeature composition', () => { }); expect(env).toEqual({ - [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( - root, - '.member-work-sync/runtime-hooks' - ), + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'), }); await expect( fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 3df99970..c1e07c91 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -193,6 +193,80 @@ async function writeOpenCodeLedgerBundle( ); } +async function writeWarningOnlyLedgerNotice( + projectDir: string, + overrides?: Partial<{ + taskId: string; + memberName: string; + message: string; + }> +): Promise { + const taskId = overrides?.taskId ?? TASK_ID; + const noticeDir = path.join(projectDir, '.board-task-changes', 'notices'); + await fs.mkdir(noticeDir, { recursive: true }); + await fs.writeFile( + path.join(noticeDir, `${encodeURIComponent(taskId)}.jsonl`), + `${JSON.stringify({ + schemaVersion: 1, + noticeId: 'notice-1', + taskId, + taskRef: taskId, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 0, + sessionId: 'session-1', + memberName: overrides?.memberName ?? 'alice', + toolUseId: 'tool-1', + timestamp: '2026-03-01T10:05:00.000Z', + severity: 'warning', + code: 'multi-scope-skipped', + message: + overrides?.message ?? + 'Task change ledger skipped attribution because multiple task scopes were active.', + })}\n`, + 'utf8' + ); +} + +async function writeOpenCodeLedgerEventJournal( + projectDir: string, + projectPath: string, + taskId: string = TASK_ID +): Promise { + const eventDir = path.join(projectDir, '.board-task-changes', 'events'); + await fs.mkdir(eventDir, { recursive: true }); + await fs.writeFile( + path.join(eventDir, `${encodeURIComponent(taskId)}.jsonl`), + `${JSON.stringify({ + schemaVersion: 1, + eventId: 'event-1', + taskId, + taskRef: taskId, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 0, + sessionId: 'opencode-session-1', + memberName: 'bob', + toolUseId: 'part-1', + source: 'opencode_toolpart_write', + operation: 'create', + confidence: 'exact', + workspaceRoot: projectPath, + filePath: path.join(projectPath, 'src/opencode.ts'), + relativePath: 'src/opencode.ts', + timestamp: '2026-03-01T10:00:00.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + oldString: '', + newString: 'export const source = "opencode";\n', + linesAdded: 1, + linesRemoved: 0, + })}\n`, + 'utf8' + ); +} + function persistedEntryPath(baseDir: string): string { return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`); } @@ -388,7 +462,9 @@ describe('ChangeExtractorService', () => { const { service } = createService({ logPaths: [] }); const getTaskChanges = vi .spyOn(service, 'getTaskChanges') - .mockImplementation(async (_teamName, taskId) => makeTaskChangeResult(taskId, { taskId })); + .mockImplementation((_teamName, taskId) => + Promise.resolve(makeTaskChangeResult(taskId, { taskId })) + ); const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [ { taskId: 'task-1', options: SUMMARY_OPTIONS }, @@ -405,7 +481,9 @@ describe('ChangeExtractorService', () => { const { service } = createService({ logPaths: [] }); const getTaskChanges = vi .spyOn(service, 'getTaskChanges') - .mockImplementation(async (_teamName, taskId) => makeTaskChangeResult(taskId, { taskId })); + .mockImplementation((_teamName, taskId) => + Promise.resolve(makeTaskChangeResult(taskId, { taskId })) + ); const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [ null, @@ -418,21 +496,48 @@ describe('ChangeExtractorService', () => { expect(getTaskChanges).toHaveBeenCalledTimes(1); }); + it('limits raw team task summary request inspection before loading', async () => { + const { service } = createService({ logPaths: [] }); + const getTaskChanges = vi + .spyOn(service, 'getTaskChanges') + .mockImplementation((_teamName, taskId) => + Promise.resolve(makeTaskChangeResult(taskId, { taskId })) + ); + + const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [ + ...Array.from({ length: 1000 }, () => null), + { taskId: 'beyond-inspect-limit', options: SUMMARY_OPTIONS }, + ] as unknown as Parameters[1]); + + expect(response.items).toEqual([]); + expect(response.truncated).toBe(true); + expect(getTaskChanges).not.toHaveBeenCalled(); + }); + 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); const aliceLogPath = path.join(tmpDir, 'alice.jsonl'); await writeJsonl(aliceLogPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); - const findLogFileRefsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) => - options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : [] + const findLogFileRefsForTask = vi.fn( + async (_teamName: string, _taskId: string, options?: any) => + options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : [] ); const service = createService({ logPaths: [aliceLogPath], findLogFileRefsForTask }).service; - const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed' }); + const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'bob', + status: 'completed', + }); const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', @@ -449,7 +554,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-summary.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath] }); @@ -477,7 +587,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-restart.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const first = createService({ logPaths: [logPath] }); @@ -500,15 +615,30 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-refresh.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const { service } = createService({ logPaths: [logPath] }); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'), - buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 2;\n', + '2026-03-01T10:00:00.000Z' + ), + buildAssistantWriteEntry( + 'tool-2', + '/repo/src/extra.ts', + 'export const extra = true;\n', + '2026-03-01T10:02:00.000Z' + ), ]); const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, { @@ -532,7 +662,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-review.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const { service } = createService({ logPaths: [logPath] }); @@ -565,7 +700,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-project-drift.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges( @@ -574,11 +714,7 @@ describe('ChangeExtractorService', () => { SUMMARY_OPTIONS ); const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' }); - await drifted.service.getTaskChanges( - TEAM_NAME, - TASK_ID, - SUMMARY_OPTIONS - ); + await drifted.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); expect((drifted.findLogFileRefsForTask as any).mock.calls.length).toBeGreaterThan(1); }); @@ -590,12 +726,25 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-missing-task.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); - await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await createService({ logPaths: [logPath] }).service.getTaskChanges( + TEAM_NAME, + TASK_ID, + SUMMARY_OPTIONS + ); await fs.unlink(taskPath); - await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await createService({ logPaths: [logPath] }).service.getTaskChanges( + TEAM_NAME, + TASK_ID, + SUMMARY_OPTIONS + ); await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' }); }); @@ -607,10 +756,19 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-corrupt.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); - await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await createService({ logPaths: [logPath] }).service.getTaskChanges( + TEAM_NAME, + TASK_ID, + SUMMARY_OPTIONS + ); vi.spyOn(console, 'warn').mockImplementation(() => {}); await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8'); @@ -630,7 +788,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-fallback.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const service = new ChangeExtractorService( @@ -669,10 +832,20 @@ describe('ChangeExtractorService', () => { const firstLogPath = path.join(tmpDir, 'first.jsonl'); const secondLogPath = path.join(tmpDir, 'second.jsonl'); await writeJsonl(firstLogPath, [ - buildAssistantWriteEntry('tool-1', 'C:\\repo\\src\\same.ts', 'first\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + 'C:\\repo\\src\\same.ts', + 'first\n', + '2026-03-01T10:00:00.000Z' + ), ]); await writeJsonl(secondLogPath, [ - buildAssistantWriteEntry('tool-2', 'C:/repo/src/same.ts', 'second\n', '2026-03-01T10:01:00.000Z'), + buildAssistantWriteEntry( + 'tool-2', + 'C:/repo/src/same.ts', + 'second\n', + '2026-03-01T10:01:00.000Z' + ), ]); const service = createService({ @@ -722,7 +895,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const computeTaskChanges = vi.fn(); @@ -752,7 +930,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const computeTaskChanges = vi.fn(async () => { @@ -783,7 +966,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const computeTaskChanges = vi.fn(async () => makeTaskChangeResult()); @@ -808,7 +996,12 @@ describe('ChangeExtractorService', () => { const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl'); await writeJsonl(logPath, [ - buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), ]); const firstWorker = { @@ -1041,7 +1234,9 @@ describe('ChangeExtractorService', () => { })); const workerClient = { isAvailable: vi.fn(() => true), - computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })), + computeTaskChanges: vi.fn(async () => + makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' }) + ), }; const { service } = createService({ logPaths: [], @@ -1057,6 +1252,196 @@ describe('ChangeExtractorService', () => { expect(upsertEntry).not.toHaveBeenCalled(); }); + it('runs OpenCode recovery when a ledger result only contains warning notices', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' }); + const projectDir = path.join(tmpDir, 'project-dir'); + const projectPath = path.join(tmpDir, 'repo'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.mkdir(projectPath, { recursive: true }); + await writeWarningOnlyLedgerNotice(projectDir, { memberName: 'bob' }); + await writeOpenCodeDeliveryLedger(tmpDir); + + const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => { + await writeOpenCodeLedgerEventJournal(input.projectDir, projectPath); + return { + schemaVersion: 1, + providerId: 'opencode', + opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION, + teamName: input.teamName, + taskId: input.taskId, + projectDir: input.projectDir, + workspaceRoot: input.workspaceRoot, + dryRun: false, + attributionMode: input.attributionMode, + scannedSessions: 1, + scannedToolparts: 1, + candidateEvents: 1, + importedEvents: 1, + skippedEvents: 0, + outcome: 'imported', + notices: [], + diagnostics: [], + }; + }); + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => + makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' }) + ), + }; + const service = new ChangeExtractorService( + { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir, + projectPath, + sessionIds: [], + })), + findLogFileRefsForTask: vi.fn(async () => []), + findMemberLogPaths: vi.fn(async () => []), + } as any, + { + parseBoundaries: vi.fn(async () => ({ + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + })), + } as any, + { getConfig: vi.fn(async () => ({ projectPath })) } as any, + undefined, + workerClient as any, + { backfillOpenCodeTaskLedger } as any, + { getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any + ); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + ...SUMMARY_OPTIONS, + owner: 'bob', + }); + + expect(result.files).toHaveLength(1); + expect(result.warnings).toContain( + 'Task change ledger skipped attribution because multiple task scopes were active.' + ); + expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1); + expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); + }); + + it('recovers Codex warning-only ledger results through the scoped worker path', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir, { owner: 'tom' }); + const projectDir = path.join(tmpDir, 'project-dir'); + const projectPath = path.join(tmpDir, 'repo'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.mkdir(projectPath, { recursive: true }); + await writeWarningOnlyLedgerNotice(projectDir, { memberName: 'tom' }); + + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => + makeTaskChangeResult(TASK_ID, { + filePath: path.join(projectPath, 'src/codex.ts'), + scope: { memberName: 'tom' }, + }) + ), + }; + const service = new ChangeExtractorService( + { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir, + projectPath, + sessionIds: [], + })), + findLogFileRefsForTask: vi.fn(async () => []), + findMemberLogPaths: vi.fn(async () => []), + } as any, + { + parseBoundaries: vi.fn(async () => ({ + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + })), + } as any, + { + getConfig: vi.fn(async () => ({ + projectPath, + members: [{ name: 'tom', providerId: 'codex' }], + })), + } as any, + undefined, + workerClient as any + ); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + ...SUMMARY_OPTIONS, + owner: 'tom', + }); + + expect(result.files).toHaveLength(1); + expect(result.warnings).toContain( + 'Task change ledger skipped attribution because multiple task scopes were active.' + ); + expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1); + }); + + it('keeps non-Codex warning-only ledger results as diagnostics instead of adding legacy changes', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir, { owner: 'atlas' }); + const projectDir = path.join(tmpDir, 'project-dir'); + const projectPath = path.join(tmpDir, 'repo'); + await fs.mkdir(projectDir, { recursive: true }); + await fs.mkdir(projectPath, { recursive: true }); + await writeWarningOnlyLedgerNotice(projectDir, { memberName: 'atlas' }); + + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID)), + }; + const service = new ChangeExtractorService( + { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir, + projectPath, + sessionIds: [], + })), + findLogFileRefsForTask: vi.fn(async () => []), + findMemberLogPaths: vi.fn(async () => []), + } as any, + { + parseBoundaries: vi.fn(async () => ({ + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + })), + } as any, + { + getConfig: vi.fn(async () => ({ + projectPath, + members: [{ name: 'atlas', providerId: 'anthropic' }], + })), + } as any, + undefined, + workerClient as any + ); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + ...SUMMARY_OPTIONS, + owner: 'atlas', + }); + + expect(result.files).toHaveLength(0); + expect(result.warnings).toContain( + 'Task change ledger skipped attribution because multiple task scopes were active.' + ); + expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); + }); + it('backfills OpenCode ledger artifacts once before falling back to legacy extraction', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); @@ -1697,7 +2082,9 @@ describe('ChangeExtractorService', () => { expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); - expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); + expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch( + /^[a-f0-9]{64}$/ + ); }); it('does not cache negative OpenCode backfill while delivery context already exists', async () => { @@ -1759,9 +2146,10 @@ describe('ChangeExtractorService', () => { skippedEvents: 0, outcome, notices: [], - diagnostics: outcome === 'transient-error' - ? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.'] - : [], + diagnostics: + outcome === 'transient-error' + ? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.'] + : [], }; }); const workerClient = { @@ -1809,11 +2197,15 @@ describe('ChangeExtractorService', () => { expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); - expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); + expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch( + /^[a-f0-9]{64}$/ + ); expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual( expect.stringContaining('delivery-context.json') ); - expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/); + expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch( + /^[a-f0-9]{64}$/ + ); }); it('does not cache duplicates-only OpenCode backfill from an old evidence contract', async () => { @@ -1902,8 +2294,7 @@ describe('ChangeExtractorService', () => { const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({ schemaVersion: 1, providerId: 'opencode', - opencodeTaskLedgerEvidenceContractVersion: - OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION, + opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION, teamName: input.teamName, taskId: input.taskId, projectDir: input.projectDir, diff --git a/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts index 06c53520..6f613324 100644 --- a/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts @@ -58,6 +58,25 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => { expect(decision.controlText).not.toContain('reportToken='); }); + it('uses review pickup repair wording for review pickup work-sync nudges', () => { + const decision = decideOpenCodePromptDeliveryRepair( + base({ + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'review_pickup', + actionMode: 'do', + taskRefs: [{ taskId: 'task-1', displayId: '#1', teamName: 'team-a' }], + responseState: 'responded_plain_text', + pendingReason: 'plain_text_ack_only_still_requires_answer', + }) + ); + + expect(decision.kind).toBe('work_sync_report_required'); + expect(decision.controlText).toContain('review pickup control message'); + expect(decision.controlText).toContain('start or continue the review'); + expect(decision.controlText).toContain('"task-1"'); + expect(decision.controlText).not.toContain('Then call agent-teams_member_work_sync_report'); + }); + it('repairs visible replies that missed required taskRefs with exact metadata', () => { const taskRef = { taskId: 'task-refs-1', displayId: 'refs-1', teamName: 'team-a' }; const decision = decideOpenCodePromptDeliveryRepair( diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index d2cc3d28..5cfe3aa2 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -593,6 +593,47 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).not.toContain('You must not end this turn empty.'); }); + it('sends review pickup work sync nudges with review-oriented response instructions', async () => { + const sendOpenCodeTeamMessage = vi.fn< + NonNullable + >(async () => ({ + accepted: true, + sessionId: 'oc-session-bob', + memberName: 'bob', + diagnostics: [], + })); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + sendOpenCodeTeamMessage, + }) + ); + + await adapter.sendMessageToMember({ + runId: 'run-1', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + text: 'Review pickup required', + messageId: 'msg-review-pickup', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'review_pickup', + workSyncReviewRequestEventIds: ['evt-review-request'], + taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }], + }); + + const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? ''; + expect(sentText).toContain('"workSyncIntent":"review_pickup"'); + expect(sentText).toContain('"workSyncReviewRequestEventIds":["evt-review-request"]'); + expect(sentText).toContain('targeted member-work-sync review pickup nudge'); + expect(sentText).toContain('review workflow tools'); + expect(sentText).toContain('Do not mark the review complete from this prompt alone.'); + expect(sentText).toContain('agent-teams_member_work_sync_report'); + expect(sentText).not.toContain('This delivered app message is a member-work-sync nudge.'); + }); + it('does not parse legacy native SendMessage wording to infer OpenCode reply recipient', async () => { const sendOpenCodeTeamMessage = vi.fn< NonNullable diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index ede2a746..86ef561b 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -8042,10 +8042,10 @@ describe('TeamProvisioningService', () => { })); await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); - await expect( - svc.deliverOpenCodeMemberMessage('team-a', { - memberName: 'bob', - text: 'Work sync check for #task-1.', + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', messageId: 'msg-work-sync-report', replyRecipient: 'team-lead', actionMode: 'do', @@ -8069,6 +8069,58 @@ describe('TeamProvisioningService', () => { }); }); + it('accepts review workflow tools as review pickup delivery response proof', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_non_visible_tool' as const, + deliveredUserMessageId: 'oc-user-review-pickup', + assistantMessageId: 'oc-assistant-review-start', + toolCallNames: ['review_start'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Review pickup required for #task-1.', + messageId: 'msg-review-pickup-start', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'review_pickup', + workSyncReviewRequestEventIds: ['evt-review-request'], + taskRefs: [ + { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }, + ], + source: 'member-work-sync-review-pickup', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'responded', + }); + }); + it('keeps member work sync status-only OpenCode deliveries pending', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ diff --git a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts index 34250c80..5d2c084c 100644 --- a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts +++ b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts @@ -431,7 +431,11 @@ describe('TaskDetailDialog changes summary loading', () => { 'task-attention', expect.objectContaining({ summaryOnly: true }) ); - expect(host.textContent).toContain('No file changes recorded'); + expect(host.textContent).toContain('No file changes were recorded for this task.'); + expect(host.textContent).toContain('No reviewable file changes recovered'); + expect(host.querySelector('[data-testid="section-badge-Changes"]')?.textContent).toBe( + 'attention' + ); await act(async () => { root.unmount(); diff --git a/test/renderer/components/team/members/MemberCardOpenCodeDeliveryAdvisory.fixture-e2e.test.tsx b/test/renderer/components/team/members/MemberCardOpenCodeDeliveryAdvisory.fixture-e2e.test.tsx new file mode 100644 index 00000000..caceabfa --- /dev/null +++ b/test/renderer/components/team/members/MemberCardOpenCodeDeliveryAdvisory.fixture-e2e.test.tsx @@ -0,0 +1,425 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; +import { TeamMemberRuntimeAdvisoryService } from '@main/services/team/TeamMemberRuntimeAdvisoryService'; +import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; +import { OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS } from '@main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy'; +import type { OpenCodePromptDeliveryLedgerRecord } from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; +import { setClaudeBasePathOverride } from '@main/utils/pathDecoder'; + +import type { + MemberRuntimeAdvisory, + ResolvedTeamMember, + TeamChangeEvent, +} from '@shared/types'; + +const hoisted = vi.hoisted(() => ({ + openExternal: vi.fn(), +})); + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: hoisted.openExternal, + }, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ + children, + className, + title, + }: { + children: React.ReactNode; + className?: string; + title?: string; + }) => React.createElement('span', { className, title }, children), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ isLight: false }), +})); + +vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({ + CurrentTaskIndicator: () => null, +})); + +import { MemberCard } from '@renderer/components/team/members/MemberCard'; + +const TEAM_NAME = 'opencode-advisory-e2e'; +const MEMBER_NAME = 'jack'; +const LANE_ID = 'secondary:opencode:jack'; +const NOW_ISO = '2026-05-09T12:05:00.000Z'; +const OLD_FAILURE_ISO = new Date( + Date.parse(NOW_ISO) - OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS - 5_000 +).toISOString(); +const FRESH_FAILURE_ISO = new Date(Date.parse(NOW_ISO) - 10_000).toISOString(); + +let tempDir = ''; +let tempClaudeRoot = ''; + +interface SideEffectHarness { + addTeamNotification: ReturnType; + sendMessageToRun: ReturnType; + teamChangeEvents: TeamChangeEvent[]; + invalidations: { teamName: string; memberName: string }[]; +} + +interface TeamProvisioningSideEffectAccess { + aliveRunByTeam: Map; + runs: Map; + sendMessageToRun: (run: unknown, text: string) => Promise; + handleOpenCodeRuntimeDeliveryUserFacingSideEffects: ( + record: OpenCodePromptDeliveryLedgerRecord + ) => Promise; + openCodeRuntimeDeliveryAdvisoryReviewTimers: Map>; +} + +const baseMember: ResolvedTeamMember = { + name: MEMBER_NAME, + status: 'unknown', + taskCount: 0, + currentTaskId: null, + lastActiveAt: null, + messageCount: 0, + color: 'purple', + agentType: 'developer', + role: 'Developer', + providerId: 'opencode', + removedAt: undefined, +}; + +describe('MemberCard OpenCode delivery advisory fixture e2e', () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW_ISO)); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-card-opencode-advisory-e2e-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + setClaudeBasePathOverride(tempClaudeRoot); + }); + + afterEach(async () => { + document.body.innerHTML = ''; + hoisted.openExternal.mockReset(); + NotificationManager.resetInstance(); + setClaudeBasePathOverride(null); + vi.unstubAllGlobals(); + vi.useRealTimers(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('keeps a fresh generic terminal failure out of the member card and user-facing side effects', async () => { + const record = makeDeliveryRecord({ + failedAt: FRESH_FAILURE_ISO, + updatedAt: FRESH_FAILURE_ISO, + lastObservedAt: FRESH_FAILURE_ISO, + respondedAt: FRESH_FAILURE_ISO, + }); + await writeDeliveryFixture(record); + + const advisory = await readMemberAdvisory(); + expect(advisory).toBeNull(); + + const cardText = await renderMemberCardText(advisory); + expect(cardText).not.toContain('OpenCode delivery error'); + expect(cardText).not.toContain('OpenCode returned an empty assistant turn'); + + const sideEffects = await runUserFacingSideEffects(record); + expect(sideEffects.addTeamNotification).not.toHaveBeenCalled(); + expect(sideEffects.sendMessageToRun).not.toHaveBeenCalled(); + expect(sideEffects.invalidations).toEqual([{ teamName: TEAM_NAME, memberName: MEMBER_NAME }]); + expect(sideEffects.teamChangeEvents).toContainEqual( + expect.objectContaining({ + type: 'member-advisory', + teamName: TEAM_NAME, + }) + ); + }); + + it('suppresses a stale terminal failure across card, notification, and lead notice after visible reply proof appears', async () => { + const record = makeDeliveryRecord({ + failedAt: OLD_FAILURE_ISO, + updatedAt: OLD_FAILURE_ISO, + lastObservedAt: OLD_FAILURE_ISO, + respondedAt: OLD_FAILURE_ISO, + }); + await writeDeliveryFixture(record); + await writeVisibleRuntimeReplyProof(record); + + const advisory = await readMemberAdvisory(); + expect(advisory).toBeNull(); + + const cardText = await renderMemberCardText(advisory); + expect(cardText).not.toContain('OpenCode delivery error'); + expect(cardText).not.toContain('OpenCode returned an empty assistant turn'); + + const sideEffects = await runUserFacingSideEffects(record); + expect(sideEffects.addTeamNotification).not.toHaveBeenCalled(); + expect(sideEffects.sendMessageToRun).not.toHaveBeenCalled(); + expect(sideEffects.invalidations).toEqual([{ teamName: TEAM_NAME, memberName: MEMBER_NAME }]); + }); + + it('still surfaces a stale terminal failure with no proof in the card, notification, and lead notice', async () => { + const record = makeDeliveryRecord({ + failedAt: OLD_FAILURE_ISO, + updatedAt: OLD_FAILURE_ISO, + lastObservedAt: OLD_FAILURE_ISO, + respondedAt: OLD_FAILURE_ISO, + }); + await writeDeliveryFixture(record); + + const advisory = await readMemberAdvisory(); + expect(advisory).toMatchObject({ + kind: 'api_error', + reasonCode: 'backend_error', + message: 'OpenCode returned an empty assistant turn.', + }); + + const cardText = await renderMemberCardText(advisory); + expect(cardText).toContain('OpenCode delivery error'); + expect(cardText).toContain('OpenCode returned an empty assistant turn.'); + + const sideEffects = await runUserFacingSideEffects(record); + expect(sideEffects.addTeamNotification).toHaveBeenCalledTimes(1); + expect(sideEffects.addTeamNotification.mock.calls[0]?.[0]).toMatchObject({ + teamEventType: 'api_error', + teamName: TEAM_NAME, + from: MEMBER_NAME, + summary: 'OpenCode runtime error #task-1', + }); + expect(sideEffects.sendMessageToRun).toHaveBeenCalledTimes(1); + expect(String(sideEffects.sendMessageToRun.mock.calls[0]?.[1])).toContain( + 'System notice: OpenCode teammate @jack hit a runtime delivery error while handling #task-1.' + ); + }); +}); + +async function readMemberAdvisory(): Promise { + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(() => Promise.resolve([])), + }); + return await service.getMemberAdvisory(TEAM_NAME, MEMBER_NAME); +} + +async function renderMemberCardText( + runtimeAdvisory: MemberRuntimeAdvisory | null +): Promise { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member: { + ...baseMember, + runtimeAdvisory: runtimeAdvisory ?? undefined, + }, + memberColor: 'purple', + runtimeSummary: 'OpenCode - kimi-k2.6', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnRuntimeAlive: true, + }) + ); + await Promise.resolve(); + }); + + const text = host.textContent ?? ''; + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + host.remove(); + return text; +} + +async function runUserFacingSideEffects( + record: OpenCodePromptDeliveryLedgerRecord +): Promise { + const addTeamNotification = vi.fn(() => Promise.resolve(undefined)); + NotificationManager.setInstance({ addTeamNotification } as never); + + const service = new TeamProvisioningService(); + const access = service as unknown as TeamProvisioningSideEffectAccess; + const sendMessageToRun = vi.fn(() => Promise.resolve(undefined)); + const teamChangeEvents: TeamChangeEvent[] = []; + const invalidations: { teamName: string; memberName: string }[] = []; + + service.setTeamChangeEmitter((event) => { + teamChangeEvents.push(event); + }); + service.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => { + invalidations.push({ teamName, memberName }); + }); + access.sendMessageToRun = sendMessageToRun; + access.aliveRunByTeam.set(TEAM_NAME, 'lead-run-1'); + access.runs.set('lead-run-1', { + runId: 'lead-run-1', + teamName: TEAM_NAME, + processKilled: false, + cancelRequested: false, + }); + + await access.handleOpenCodeRuntimeDeliveryUserFacingSideEffects(record); + for (const timer of access.openCodeRuntimeDeliveryAdvisoryReviewTimers.values()) { + clearTimeout(timer); + } + access.openCodeRuntimeDeliveryAdvisoryReviewTimers.clear(); + + return { + addTeamNotification, + sendMessageToRun, + teamChangeEvents, + invalidations, + }; +} + +function makeDeliveryRecord( + overrides: Partial = {} +): OpenCodePromptDeliveryLedgerRecord { + return { + id: 'opencode-prompt:msg-empty-turn', + teamName: TEAM_NAME, + memberName: MEMBER_NAME, + laneId: LANE_ID, + runId: 'opencode-run-1', + runtimeSessionId: 'session-jack', + inboxMessageId: 'msg-empty-turn', + inboxTimestamp: overrides.inboxTimestamp ?? OLD_FAILURE_ISO, + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: 'ask', + taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName: TEAM_NAME }], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'empty_assistant_turn', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: overrides.lastAttemptAt ?? OLD_FAILURE_ISO, + lastObservedAt: overrides.lastObservedAt ?? OLD_FAILURE_ISO, + acceptedAt: overrides.acceptedAt ?? OLD_FAILURE_ISO, + respondedAt: overrides.respondedAt ?? OLD_FAILURE_ISO, + failedAt: overrides.failedAt ?? OLD_FAILURE_ISO, + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'opencode-user-msg-1', + observedAssistantMessageId: 'opencode-assistant-empty', + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'empty_assistant_turn', + diagnostics: ['empty_assistant_turn'], + createdAt: overrides.createdAt ?? OLD_FAILURE_ISO, + updatedAt: overrides.updatedAt ?? OLD_FAILURE_ISO, + ...overrides, + }; +} + +async function writeDeliveryFixture(record: OpenCodePromptDeliveryLedgerRecord): Promise { + const teamDir = path.join(tempClaudeRoot, 'teams', TEAM_NAME); + const runtimeDir = path.join(teamDir, '.opencode-runtime'); + const laneDir = path.join(runtimeDir, 'lanes', encodeURIComponent(LANE_ID)); + await fs.mkdir(laneDir, { recursive: true }); + await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + `${JSON.stringify( + { + name: TEAM_NAME, + projectPath: path.join(tempDir, 'project'), + leadSessionId: 'lead-session', + members: [ + { name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }, + { name: MEMBER_NAME, role: 'Developer', providerId: 'opencode' }, + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); + await fs.writeFile( + path.join(runtimeDir, 'lanes.json'), + `${JSON.stringify( + { + version: 1, + updatedAt: record.updatedAt, + lanes: { + [LANE_ID]: { + laneId: LANE_ID, + state: 'active', + updatedAt: record.updatedAt, + }, + }, + }, + null, + 2 + )}\n`, + 'utf8' + ); + await fs.writeFile( + path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), + `${JSON.stringify( + { + schemaVersion: 1, + updatedAt: record.updatedAt, + data: [record], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function writeVisibleRuntimeReplyProof( + record: OpenCodePromptDeliveryLedgerRecord +): Promise { + await fs.writeFile( + path.join(tempClaudeRoot, 'teams', TEAM_NAME, 'inboxes', 'team-lead.json'), + `${JSON.stringify( + [ + { + from: MEMBER_NAME, + to: 'team-lead', + text: 'Done, visible reply already delivered.', + timestamp: NOW_ISO, + read: false, + source: 'runtime_delivery', + messageId: 'visible-runtime-reply-1', + relayOfMessageId: record.inboxMessageId, + taskRefs: record.taskRefs, + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); +} diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 2655e2e0..5d73207a 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -542,6 +542,89 @@ describe('teamSlice actions', () => { }); }); + it('checks the original message when queued blocker impact is no longer user-visible', async () => { + const store = createSliceStore(); + hoisted.sendMessage.mockResolvedValue({ + deliveredToInbox: true, + messageId: 'm-opencode-queued', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + responseState: 'pending', + ledgerStatus: 'accepted', + queuedBehindMessageId: 'm-opencode-blocker', + reason: 'opencode_delivery_response_pending', + diagnostics: ['opencode_delivery_response_pending'], + userVisibleImpact: { + state: 'checking', + }, + }, + }); + hoisted.getOpenCodeRuntimeDeliveryStatus + .mockResolvedValueOnce({ + messageId: 'm-opencode-blocker', + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'responded', + acceptanceUnknown: false, + reason: 'non_visible_tool_without_task_progress', + diagnostics: ['non_visible_tool_without_task_progress'], + userVisibleImpact: { + state: 'none', + }, + }) + .mockResolvedValueOnce({ + messageId: 'm-opencode-queued', + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'empty_assistant_turn', + ledgerStatus: 'failed_terminal', + acceptanceUnknown: false, + reason: 'empty_assistant_turn', + diagnostics: ['empty_assistant_turn'], + userVisibleImpact: { + state: 'error', + reasonCode: 'backend_error', + message: 'empty_assistant_turn', + }, + }); + + await store.getState().sendTeamMessage('my-team', { + member: 'bob', + text: 'hello', + }); + await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', { + messageId: 'm-opencode-queued', + statusMessageId: 'm-opencode-blocker', + }); + + expect(hoisted.getOpenCodeRuntimeDeliveryStatus).toHaveBeenNthCalledWith( + 1, + 'my-team', + 'm-opencode-blocker' + ); + expect(hoisted.getOpenCodeRuntimeDeliveryStatus).toHaveBeenNthCalledWith( + 2, + 'my-team', + 'm-opencode-queued' + ); + expect(store.getState().sendMessageWarning).toBe( + 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.' + ); + expect(store.getState().sendMessageDebugDetails).toMatchObject({ + messageId: 'm-opencode-queued', + statusMessageId: 'm-opencode-queued', + userVisibleState: 'error', + }); + }); + it('clears OpenCode runtime diagnostics only for the matching message id', async () => { const store = createSliceStore(); hoisted.sendMessage.mockResolvedValue({ diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index cd51e658..85913182 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { buildOpenCodeRuntimeDeliveryDiagnostics } from '../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics'; +import { + buildOpenCodeRuntimeDeliveryDiagnostics, + shouldClearPendingReplyForOpenCodeRuntimeDelivery, +} from '../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics'; describe('openCodeRuntimeDeliveryDiagnostics', () => { it('honors user-visible checking impact over raw terminal delivery facts', () => { @@ -58,6 +61,22 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { expect(diagnostics).toEqual({ warning: null, debugDetails: null }); }); + it('clears pending reply when user-visible none impact overrides raw pending facts', () => { + expect( + shouldClearPendingReplyForOpenCodeRuntimeDelivery({ + providerId: 'opencode', + attempted: true, + delivered: true, + responsePending: true, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'responded', + userVisibleImpact: { + state: 'none', + }, + }) + ).toBe(true); + }); + it('surfaces terminal empty assistant turn in the compact failed warning', () => { const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ deliveredToInbox: true,