feat(sync): expand member work review signals

This commit is contained in:
777genius 2026-05-09 14:34:33 +03:00
parent 2cfb3d9dc9
commit bda2af87e4
51 changed files with 4107 additions and 530 deletions

View file

@ -75,11 +75,26 @@ function normalizeMessageKind(messageKind) {
return messageKind === 'default' || return messageKind === 'default' ||
messageKind === 'slash_command' || messageKind === 'slash_command' ||
messageKind === 'slash_command_result' || messageKind === 'slash_command_result' ||
messageKind === 'task_comment_notification' messageKind === 'task_comment_notification' ||
messageKind === 'member_work_sync_nudge'
? messageKind ? messageKind
: undefined; : 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) { function normalizeSlashCommand(slashCommand) {
if (!slashCommand || typeof slashCommand !== 'object') { if (!slashCommand || typeof slashCommand !== 'object') {
return undefined; return undefined;
@ -123,6 +138,8 @@ function buildMessage(flags, defaults) {
const attachments = normalizeAttachments(flags.attachments); const attachments = normalizeAttachments(flags.attachments);
const taskRefs = normalizeTaskRefs(flags.taskRefs); const taskRefs = normalizeTaskRefs(flags.taskRefs);
const messageKind = normalizeMessageKind(flags.messageKind); const messageKind = normalizeMessageKind(flags.messageKind);
const workSyncIntent = normalizeWorkSyncIntent(flags.workSyncIntent);
const workSyncReviewRequestEventIds = normalizeStringList(flags.workSyncReviewRequestEventIds);
const slashCommand = normalizeSlashCommand(flags.slashCommand); const slashCommand = normalizeSlashCommand(flags.slashCommand);
const commandOutput = normalizeCommandOutput(flags.commandOutput); const commandOutput = normalizeCommandOutput(flags.commandOutput);
@ -173,6 +190,11 @@ function buildMessage(flags, defaults) {
} }
: {}), : {}),
...(messageKind ? { messageKind } : {}), ...(messageKind ? { messageKind } : {}),
...(workSyncIntent ? { workSyncIntent } : {}),
...(typeof flags.workSyncIntentKey === 'string' && flags.workSyncIntentKey.trim()
? { workSyncIntentKey: flags.workSyncIntentKey.trim() }
: {}),
...(workSyncReviewRequestEventIds ? { workSyncReviewRequestEventIds } : {}),
...(slashCommand ? { slashCommand } : {}), ...(slashCommand ? { slashCommand } : {}),
...(commandOutput ? { commandOutput } : {}), ...(commandOutput ? { commandOutput } : {}),
...(attachments ? { attachments } : {}), ...(attachments ? { attachments } : {}),

View file

@ -1130,7 +1130,10 @@ describe('agent-teams-controller API', () => {
commentId: 'comment-123', commentId: 'comment-123',
relayOfMessageId: 'm-original-1', relayOfMessageId: 'm-original-1',
source: 'system_notification', 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', leadSessionId: 'session-42',
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }], 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')); const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('system_notification'); 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].commentId).toBe('comment-123');
expect(rows[0].relayOfMessageId).toBe('m-original-1'); expect(rows[0].relayOfMessageId).toBe('m-original-1');
expect(rows[0].leadSessionId).toBe('session-42'); expect(rows[0].leadSessionId).toBe('session-42');

View file

@ -1,3 +1,4 @@
export { AgentAttachmentError } from '../core/domain';
export * from './infrastructure/attachmentArtifactStore'; export * from './infrastructure/attachmentArtifactStore';
export * from './providers/claudeAttachmentAdapter'; export * from './providers/claudeAttachmentAdapter';
export * from './providers/codexNativeAttachmentAdapter'; export * from './providers/codexNativeAttachmentAdapter';

View file

@ -22,6 +22,15 @@ export type MemberWorkSyncActionableWorkPriority =
export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; 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 { export interface MemberWorkSyncActionableWorkItem {
taskId: string; taskId: string;
displayId?: string; displayId?: string;
@ -35,6 +44,15 @@ export interface MemberWorkSyncActionableWorkItem {
owner?: string; owner?: string;
reviewer?: string; reviewer?: string;
reviewState?: string; reviewState?: string;
reviewCycleId?: string;
reviewRequestEventId?: string;
reviewRequestedAt?: string;
reviewStartedEventId?: string;
reviewStartedAt?: string;
reviewStartedBy?: string;
reviewObligation?: MemberWorkSyncReviewObligation;
canBypassPhase2?: boolean;
reviewDiagnostics?: string[];
needsClarification?: 'lead' | 'user'; needsClarification?: 'lead' | 'user';
blockerTaskIds?: string[]; blockerTaskIds?: string[];
blockedByTaskIds?: string[]; blockedByTaskIds?: string[];
@ -220,6 +238,9 @@ export interface MemberWorkSyncNudgePayload {
messageKind: 'member_work_sync_nudge'; messageKind: 'member_work_sync_nudge';
source: 'member-work-sync'; source: 'member-work-sync';
actionMode: 'do'; actionMode: 'do';
workSyncIntent: MemberWorkSyncNudgeIntent;
workSyncIntentKey?: string;
workSyncReviewRequestEventIds?: string[];
text: string; text: string;
taskRefs: { taskRefs: {
taskId: string; taskId: string;
@ -240,6 +261,8 @@ export interface MemberWorkSyncOutboxItem {
claimedBy?: string; claimedBy?: string;
claimedAt?: string; claimedAt?: string;
deliveredMessageId?: string; deliveredMessageId?: string;
deliveryState?: MemberWorkSyncReviewPickupDeliveryState;
deliveryDiagnostics?: string[];
lastError?: string; lastError?: string;
nextAttemptAt?: string; nextAttemptAt?: string;
createdAt: string; createdAt: string;
@ -279,6 +302,8 @@ export interface MemberWorkSyncOutboxMarkDeliveredInput {
id: string; id: string;
attemptGeneration: number; attemptGeneration: number;
deliveredMessageId: string; deliveredMessageId: string;
deliveryState?: MemberWorkSyncReviewPickupDeliveryState;
deliveryDiagnostics?: string[];
nowIso: string; nowIso: string;
} }

View file

@ -3,6 +3,7 @@ import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../cont
export type MemberWorkSyncNudgeActivationReason = export type MemberWorkSyncNudgeActivationReason =
| 'shadow_ready' | 'shadow_ready'
| 'opencode_targeted_shadow_collecting' | 'opencode_targeted_shadow_collecting'
| 'review_pickup_required'
| 'status_not_nudgeable' | 'status_not_nudgeable'
| 'blocking_metrics' | 'blocking_metrics'
| 'phase2_not_ready'; | 'phase2_not_ready';
@ -27,10 +28,35 @@ function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean {
status.providerId === 'opencode' && status.providerId === 'opencode' &&
status.state === 'needs_sync' && status.state === 'needs_sync' &&
status.agenda.items.length > 0 && status.agenda.items.length > 0 &&
!isReviewPickupAgenda(status) &&
status.shadow?.wouldNudge === true 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: { export function decideMemberWorkSyncNudgeActivation(input: {
status: MemberWorkSyncStatus; status: MemberWorkSyncStatus;
metrics: MemberWorkSyncTeamMetrics; metrics: MemberWorkSyncTeamMetrics;
@ -43,6 +69,10 @@ export function decideMemberWorkSyncNudgeActivation(input: {
return { active: false, reason: 'blocking_metrics' }; return { active: false, reason: 'blocking_metrics' };
} }
if (isReviewPickupRequiredCandidate(input.status)) {
return { active: true, reason: 'review_pickup_required' };
}
if (input.metrics.phase2Readiness.state === 'shadow_ready') { if (input.metrics.phase2Readiness.state === 'shadow_ready') {
return { active: true, reason: 'shadow_ready' }; return { active: true, reason: 'shadow_ready' };
} }

View file

@ -4,7 +4,11 @@ import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncA
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy'; import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler'; import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts'; import type {
MemberWorkSyncAgenda,
MemberWorkSyncOutboxItem,
MemberWorkSyncStatus,
} from '../../contracts';
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports'; import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; 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)); 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 { export class MemberWorkSyncNudgeDispatcher {
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
@ -114,6 +154,10 @@ export class MemberWorkSyncNudgeDispatcher {
); );
return 'retryable'; return 'retryable';
} }
if (revalidation.reason.startsWith('review_pickup_delivery_unavailable:')) {
await this.markReviewPickupDeliveryUnavailable(item, nowIso, revalidation.reason);
return 'superseded';
}
await outbox.markSuperseded({ await outbox.markSuperseded({
teamName: item.teamName, teamName: item.teamName,
id: item.id, id: item.id,
@ -145,6 +189,15 @@ export class MemberWorkSyncNudgeDispatcher {
await this.appendDispatchAudit(item, 'nudge_skipped', 'inbox_payload_conflict'); await this.appendDispatchAudit(item, 'nudge_skipped', 'inbox_payload_conflict');
return 'terminal'; return 'terminal';
} }
if (isReviewPickupOutboxItem(item)) {
return await this.deliverReviewPickupNudge(
item,
inserted.messageId,
inserted.inserted,
revalidation.providerId,
nowIso
);
}
await outbox.markDelivered({ await outbox.markDelivered({
teamName: item.teamName, teamName: item.teamName,
id: item.id, 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<keyof Omit<MemberWorkSyncNudgeDispatchSummary, 'claimed'>> {
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<void> {
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<void> {
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( private async appendDispatchAudit(
item: MemberWorkSyncOutboxItem, item: MemberWorkSyncOutboxItem,
event: MemberWorkSyncAuditEventName, event: MemberWorkSyncAuditEventName,
@ -248,11 +421,10 @@ export class MemberWorkSyncNudgeDispatcher {
diagnostics: [...agenda.diagnostics, ...decision.diagnostics], diagnostics: [...agenda.diagnostics, ...decision.diagnostics],
...(providerId ? { providerId } : {}), ...(providerId ? { providerId } : {}),
}; };
if ( const agendaStillMatches =
decision.state !== 'needs_sync' || agenda.fingerprint === item.agendaFingerprint ||
agenda.items.length === 0 || (isReviewPickupOutboxItem(item) && reviewPickupRequestIdsStillMatch(item, agenda));
agenda.fingerprint !== item.agendaFingerprint if (decision.state !== 'needs_sync' || agenda.items.length === 0 || !agendaStillMatches) {
) {
return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false }; return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false };
} }
@ -265,7 +437,30 @@ export class MemberWorkSyncNudgeDispatcher {
metrics, metrics,
}); });
if (!activation.active) { 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({ const recentDelivered = await this.deps.outboxStore?.countRecentDelivered({

View file

@ -6,13 +6,44 @@ import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActiva
import type { MemberWorkSyncStatus } from '../../contracts'; import type { MemberWorkSyncStatus } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports'; 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 { export interface MemberWorkSyncNudgeOutboxPlanResult {
planned: boolean; planned: boolean;
code: code:
| 'outbox_unavailable' | 'outbox_unavailable'
| 'metrics_unavailable' | 'metrics_unavailable'
| 'status_not_nudgeable' | 'status_not_nudgeable'
| 'blocking_metrics'
| 'phase2_not_ready' | 'phase2_not_ready'
| 'review_pickup_delivery_unavailable'
| 'review_pickup_already_delivered_still_stuck'
| 'review_pickup_delivery_failed_still_stuck'
| 'created' | 'created'
| 'existing' | 'existing'
| 'payload_conflict'; | 'payload_conflict';
@ -29,7 +60,7 @@ export class MemberWorkSyncNudgeOutboxPlanner {
return { planned: false, code: 'metrics_unavailable' }; return { planned: false, code: 'metrics_unavailable' };
} }
const input = buildMemberWorkSyncOutboxEnsureInput({ let input = buildMemberWorkSyncOutboxEnsureInput({
status, status,
hash: this.deps.hash, hash: this.deps.hash,
nowIso: status.evaluatedAt, nowIso: status.evaluatedAt,
@ -41,12 +72,76 @@ export class MemberWorkSyncNudgeOutboxPlanner {
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName); const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
const activation = decideMemberWorkSyncNudgeActivation({ status, metrics }); const activation = decideMemberWorkSyncNudgeActivation({ status, metrics });
if (!activation.active) { if (!activation.active) {
await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' }); const code =
return { planned: false, code: 'phase2_not_ready' }; 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); const result = await this.deps.outboxStore.ensurePending(input);
if (!result.ok) { 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', { this.deps.logger?.warn('member work sync nudge outbox payload conflict', {
teamName: status.teamName, teamName: status.teamName,
memberName: status.memberName, memberName: status.memberName,
@ -58,11 +153,108 @@ export class MemberWorkSyncNudgeOutboxPlanner {
return { planned: false, code: 'payload_conflict' }; 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; const planResult = { planned: true, code: result.outcome } as const;
await this.appendPlanAudit(status, planResult); await this.appendPlanAudit(status, planResult);
return planResult; return planResult;
} }
private async appendReviewPickupEscalationAudit(
status: MemberWorkSyncStatus,
reason: string
): Promise<void> {
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<void> {
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<void> {
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( private async appendPlanAudit(
status: MemberWorkSyncStatus, status: MemberWorkSyncStatus,
result: MemberWorkSyncNudgeOutboxPlanResult result: MemberWorkSyncNudgeOutboxPlanResult

View file

@ -86,6 +86,10 @@ export type MemberWorkSyncAuditEventName =
| 'nudge_skipped' | 'nudge_skipped'
| 'nudge_retryable' | 'nudge_retryable'
| 'nudge_superseded' | 'nudge_superseded'
| 'review_pickup_delivery_unavailable'
| 'review_pickup_member_nudge_delivered'
| 'review_pickup_escalated'
| 'review_pickup_wake_failed_retryable'
| 'watchdog_cooldown_active' | 'watchdog_cooldown_active'
| 'member_busy' | 'member_busy'
| 'team_inactive' | 'team_inactive'
@ -152,6 +156,11 @@ export interface MemberWorkSyncOutboxStorePort {
markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void>; markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void>;
markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void>; markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void>;
countRecentDelivered(input: MemberWorkSyncOutboxCountRecentDeliveredInput): Promise<number>; countRecentDelivered(input: MemberWorkSyncOutboxCountRecentDeliveredInput): Promise<number>;
findDeliveredReviewPickupRequestEventIds?(input: {
teamName: string;
memberName: string;
reviewRequestEventIds: string[];
}): Promise<string[]>;
} }
export interface MemberWorkSyncInboxNudgePort { export interface MemberWorkSyncInboxNudgePort {
@ -193,6 +202,56 @@ export interface MemberWorkSyncNudgeDeliveryWakePort {
}): Promise<void> | void; }): Promise<void> | 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<MemberWorkSyncReviewPickupDeliveryOutcome>;
}
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> | void;
}
export interface MemberWorkSyncUseCaseDeps { export interface MemberWorkSyncUseCaseDeps {
clock: MemberWorkSyncClockPort; clock: MemberWorkSyncClockPort;
hash: MemberWorkSyncHashPort; hash: MemberWorkSyncHashPort;
@ -204,6 +263,8 @@ export interface MemberWorkSyncUseCaseDeps {
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort; watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
busySignal?: MemberWorkSyncBusySignalPort; busySignal?: MemberWorkSyncBusySignalPort;
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort; nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
reportToken?: MemberWorkSyncReportTokenPort; reportToken?: MemberWorkSyncReportTokenPort;
auditJournal?: MemberWorkSyncAuditJournalPort; auditJournal?: MemberWorkSyncAuditJournalPort;
lifecycle?: MemberWorkSyncLifecyclePort; lifecycle?: MemberWorkSyncLifecyclePort;

View file

@ -10,7 +10,7 @@ import {
canonicalizeAgendaFingerprintPayload, canonicalizeAgendaFingerprintPayload,
formatAgendaFingerprint, formatAgendaFingerprint,
} from './AgendaFingerprint'; } from './AgendaFingerprint';
import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle'; import { resolveCurrentReviewCycle, type ReviewHistoryEventLike } from './currentReviewCycle';
import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName';
import type { import type {
@ -180,15 +180,44 @@ export function buildActionableWorkAgenda(
continue; continue;
} }
const reviewOwner = isReviewWorkflow const reviewCycle = isReviewWorkflow
? resolveCurrentReviewOwner({ ? resolveCurrentReviewCycle({
reviewState: workflowColumn, reviewState: workflowColumn,
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null, kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
historyEvents: task.historyEvents, historyEvents: task.historyEvents,
}) })
: null; : 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({ items.push({
...base, ...base,
kind: 'review', kind: 'review',
@ -198,9 +227,30 @@ export function buildActionableWorkAgenda(
status: task.status, status: task.status,
...(owner ? { owner } : {}), ...(owner ? { owner } : {}),
reviewer: memberName, reviewer: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}), reviewState: workflowColumn,
...(reviewOwner.historyEventIds.length > 0 reviewCycleId: reviewCycle.reviewCycleId,
? { historyEventIds: reviewOwner.historyEventIds } 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() }
: {}), : {}),
}, },
}); });

View file

@ -66,6 +66,9 @@ export function buildAgendaFingerprintPayload(input: {
...(item.evidence.historyEventIds ...(item.evidence.historyEventIds
? { historyEventIds: [...item.evidence.historyEventIds].sort() } ? { historyEventIds: [...item.evidence.historyEventIds].sort() }
: {}), : {}),
...(item.evidence.reviewDiagnostics
? { reviewDiagnostics: [...item.evidence.reviewDiagnostics].sort() }
: {}),
}, },
})), })),
}; };

View file

@ -29,18 +29,100 @@ export function buildMemberWorkSyncNudgeId(input: {
teamName: string; teamName: string;
memberName: string; memberName: string;
agendaFingerprint: string; agendaFingerprint: string;
intentKey?: string;
}): string { }): string {
return [ return [
MEMBER_WORK_SYNC_NUDGE_ID_PREFIX, MEMBER_WORK_SYNC_NUDGE_ID_PREFIX,
input.teamName, input.teamName,
input.memberName.trim().toLowerCase(), input.memberName.trim().toLowerCase(),
input.agendaFingerprint, input.intentKey ?? input.agendaFingerprint,
].join(':'); ].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( export function buildMemberWorkSyncNudgePayload(
status: MemberWorkSyncStatus status: MemberWorkSyncStatus
): MemberWorkSyncNudgePayload { ): MemberWorkSyncNudgePayload {
if (isReviewPickupNudgeStatus(status)) {
return buildReviewPickupNudgePayload(status);
}
const taskRefs = status.agenda.items.map((item) => ({ const taskRefs = status.agenda.items.map((item) => ({
teamName: status.teamName, teamName: status.teamName,
taskId: item.taskId, taskId: item.taskId,
@ -58,6 +140,7 @@ export function buildMemberWorkSyncNudgePayload(
messageKind: 'member_work_sync_nudge', messageKind: 'member_work_sync_nudge',
source: 'member-work-sync', source: 'member-work-sync',
actionMode: 'do', actionMode: 'do',
workSyncIntent: 'agenda_sync',
taskRefs, taskRefs,
text: [ text: [
'Work sync check: you have current actionable work assigned.', 'Work sync check: you have current actionable work assigned.',
@ -98,11 +181,14 @@ export function buildMemberWorkSyncOutboxEnsureInput(input: {
} }
const payload = buildMemberWorkSyncNudgePayload(status); const payload = buildMemberWorkSyncNudgePayload(status);
const intentKey =
payload.workSyncIntent === 'review_pickup' ? payload.workSyncIntentKey : undefined;
return { return {
id: buildMemberWorkSyncNudgeId({ id: buildMemberWorkSyncNudgeId({
teamName: status.teamName, teamName: status.teamName,
memberName: status.memberName, memberName: status.memberName,
agendaFingerprint: status.agenda.fingerprint, agendaFingerprint: status.agenda.fingerprint,
intentKey,
}), }),
teamName: status.teamName, teamName: status.teamName,
memberName: status.memberName, memberName: status.memberName,

View file

@ -18,20 +18,45 @@ export type MemberWorkSyncReportTokenValidation =
| { ok: false; reason: 'missing' | 'expired' | 'invalid' }; | { ok: false; reason: 'missing' | 'expired' | 'invalid' };
const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000; 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 DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000;
const MIN_LEASE_MS = 60_000; const MIN_LEASE_MS = 60_000;
const MAX_LEASE_MS = 60 * 60 * 1000; 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( function clampLeaseTtlMs(
value: number | undefined, value: number | undefined,
state: MemberWorkSyncReportState state: MemberWorkSyncReportState,
agenda: MemberWorkSyncAgenda
): number | undefined { ): number | undefined {
if (state === 'caught_up') { if (state === 'caught_up') {
return undefined; 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; 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( 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 { return {
ok: true, ok: true,
code: 'accepted', code: 'accepted',

View file

@ -10,21 +10,214 @@ export interface ReviewHistoryEventLike {
to?: string; 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 { export interface CurrentReviewOwner {
reviewer: string; reviewer: string;
historyEventIds: string[]; historyEventIds: string[];
} }
function compareEventsByTimestamp( interface IndexedReviewEvent {
left: ReviewHistoryEventLike, event: ReviewHistoryEventLike;
right: ReviewHistoryEventLike index: number;
): number { }
const leftTime = Date.parse(left.timestamp ?? '');
const rightTime = Date.parse(right.timestamp ?? ''); interface OpenReviewCycleEvidence {
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { 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 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: { export function resolveCurrentReviewOwner(input: {
@ -32,52 +225,11 @@ export function resolveCurrentReviewOwner(input: {
kanbanReviewer?: string | null; kanbanReviewer?: string | null;
historyEvents?: ReviewHistoryEventLike[]; historyEvents?: ReviewHistoryEventLike[];
}): CurrentReviewOwner | null { }): CurrentReviewOwner | null {
if (input.reviewState !== 'review') { const cycle = resolveCurrentReviewCycle(input);
return null; return cycle
} ? {
reviewer: cycle.reviewer,
const historyEvents = [...(input.historyEvents ?? [])] historyEventIds: cycle.historyEventIds,
.filter((event) => }
[ : null;
'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
),
};
} }

View file

@ -4,7 +4,11 @@ import {
isTeamTaskTerminalForActionableWork, isTeamTaskTerminalForActionableWork,
} from '@shared/utils/teamTaskState'; } 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 { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
@ -144,12 +148,22 @@ export class MemberWorkSyncTaskImpactResolver {
const reviewOwner = const reviewOwner =
taskWorkflowColumn === 'review' taskWorkflowColumn === 'review'
? resolveCurrentReviewOwner({ ? resolveCurrentReviewOwner({
reviewState: task.reviewState, reviewState: taskWorkflowColumn,
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null, kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
historyEvents: task.historyEvents, historyEvents: task.historyEvents,
}) })
: null; : 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) { if (taskWorkflowColumn === 'review' && !reviewOwner?.reviewer) {
addLead(); addLead();

View file

@ -27,6 +27,9 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
summary: 'Work sync check', summary: 'Work sync check',
source: 'system_notification', source: 'system_notification',
messageKind: input.payload.messageKind, messageKind: input.payload.messageKind,
workSyncIntent: input.payload.workSyncIntent,
workSyncIntentKey: input.payload.workSyncIntentKey,
workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds,
}); });
return { return {

View file

@ -51,6 +51,8 @@ import type {
MemberWorkSyncBusySignalPort, MemberWorkSyncBusySignalPort,
MemberWorkSyncLoggerPort, MemberWorkSyncLoggerPort,
MemberWorkSyncNudgeDeliveryWakePort, MemberWorkSyncNudgeDeliveryWakePort,
MemberWorkSyncReviewPickupDeliveryPort,
MemberWorkSyncReviewPickupEscalationPort,
} from '../../core/application'; } from '../../core/application';
import type { RuntimeTurnSettledProvider } from '../../core/domain'; import type { RuntimeTurnSettledProvider } from '../../core/domain';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; 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 { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamChangeEvent } from '@shared/types'; 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: { export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
teamsBasePath: string; teamsBasePath: string;
provider: RuntimeTurnSettledProvider; provider: RuntimeTurnSettledProvider;
@ -100,6 +130,8 @@ export function createMemberWorkSyncFeature(deps: {
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort; runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
extraBusySignals?: MemberWorkSyncBusySignalPort[]; extraBusySignals?: MemberWorkSyncBusySignalPort[];
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort; nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
logger?: MemberWorkSyncLoggerPort; logger?: MemberWorkSyncLoggerPort;
}): MemberWorkSyncFeatureFacade { }): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter(); const clock = new SystemClockAdapter();
@ -163,6 +195,8 @@ export function createMemberWorkSyncFeature(deps: {
watchdogCooldown, watchdogCooldown,
busySignal, busySignal,
...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}), ...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}),
...(deps.reviewPickupDelivery ? { reviewPickupDelivery: deps.reviewPickupDelivery } : {}),
...(deps.reviewPickupEscalation ? { reviewPickupEscalation: deps.reviewPickupEscalation } : {}),
reportToken, reportToken,
auditJournal, auditJournal,
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
@ -240,8 +274,27 @@ export function createMemberWorkSyncFeature(deps: {
runtimeTurnSettledDrainScheduler.start(); runtimeTurnSettledDrainScheduler.start();
nudgeDispatchScheduler?.start(); nudgeDispatchScheduler?.start();
const readStatusWithStaleRefresh = async (
request: MemberWorkSyncStatusRequest
): Promise<MemberWorkSyncStatus> => {
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 { return {
getStatus: (request) => diagnosticsReader.execute(request), getStatus: readStatusWithStaleRefresh,
refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }), refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }),
getMetrics: (request) => metricsReader.execute(request), getMetrics: (request) => metricsReader.execute(request),
report: (request) => reporter.execute(request), report: (request) => reporter.execute(request),

View file

@ -252,6 +252,30 @@ function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boo
return item.nextAttemptAt <= nowIso; return item.nextAttemptAt <= nowIso;
} }
function getReviewPickupIntentKey(item: Pick<MemberWorkSyncOutboxItem, 'payload'>): 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( function getDueOutboxRoutes(
index: OutboxIndexFile, index: OutboxIndexFile,
nowIso: string, nowIso: string,
@ -697,6 +721,30 @@ export class JsonMemberWorkSyncStore
const current = outbox.items[input.id]; const current = outbox.items[input.id];
if (current) { if (current) {
if (current.payloadHash !== input.payloadHash) { 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 = { result = {
ok: false, ok: false,
outcome: 'payload_conflict', outcome: 'payload_conflict',
@ -828,6 +876,10 @@ export class JsonMemberWorkSyncStore
...current, ...current,
status: 'delivered', status: 'delivered',
deliveredMessageId: input.deliveredMessageId, deliveredMessageId: input.deliveredMessageId,
...(input.deliveryState ? { deliveryState: input.deliveryState } : {}),
...(input.deliveryDiagnostics?.length
? { deliveryDiagnostics: input.deliveryDiagnostics }
: {}),
updatedAt: input.nowIso, updatedAt: input.nowIso,
}; };
delete next.lastError; delete next.lastError;
@ -902,6 +954,32 @@ export class JsonMemberWorkSyncStore
return Math.max(indexedCount, memberFileCount); return Math.max(indexedCount, memberFileCount);
} }
async findDeliveredReviewPickupRequestEventIds(input: {
teamName: string;
memberName: string;
reviewRequestEventIds: string[];
}): Promise<string[]> {
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<string>();
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<LegacyStatusFile> { private async readLegacyStatusFile(teamName: string): Promise<LegacyStatusFile> {
return readJsonFile( return readJsonFile(
this.paths.getLegacyStatusPath(teamName), this.paths.getLegacyStatusPath(teamName),

View file

@ -101,6 +101,7 @@ import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idl
import { parseInboxJson } from '@shared/utils/inboxNoise'; import { parseInboxJson } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
import { createHash } from 'crypto';
import { app, BrowserWindow, ipcMain } from 'electron'; import { app, BrowserWindow, ipcMain } from 'electron';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@ -248,6 +249,7 @@ if (process.platform === 'win32') {
// --- Team message notification tracking --- // --- Team message notification tracking ---
const teamInboxReader = new TeamInboxReader(); const teamInboxReader = new TeamInboxReader();
const teamInboxWriter = new TeamInboxWriter();
const sentMessagesStore = new TeamSentMessagesStore(); const sentMessagesStore = new TeamSentMessagesStore();
/** Track last-seen message count per inbox file to detect new messages. */ /** Track last-seen message count per inbox file to detect new messages. */
const inboxMessageCounts = new Map<string, number>(); const inboxMessageCounts = new Map<string, number>();
@ -256,9 +258,58 @@ const sentMessageCounts = new Map<string, number>();
/** Debounce per-inbox to avoid flooding during batch writes. */ /** Debounce per-inbox to avoid flooding during batch writes. */
const inboxNotifyTimers = new Map<string, ReturnType<typeof setTimeout>>(); const inboxNotifyTimers = new Map<string, ReturnType<typeof setTimeout>>();
const INBOX_NOTIFY_DEBOUNCE_MS = 500; 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']); 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( async function createOpenCodeRuntimeAdapterRegistry(
reportProgress: (phase: string, message: string) => void = () => undefined reportProgress: (phase: string, message: string) => void = () => undefined
): Promise<TeamRuntimeAdapterRegistry> { ): Promise<TeamRuntimeAdapterRegistry> {
@ -1553,6 +1604,108 @@ async function initializeServices(): Promise<void> {
}); });
}, },
}, },
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'), logger: createLogger('Feature:MemberWorkSync'),
}); });
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) => teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>

View file

@ -59,6 +59,7 @@ import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
const wrapReviewHandler = createIpcWrapper('IPC:review'); const wrapReviewHandler = createIpcWrapper('IPC:review');
const logger = createLogger('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; const TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT = 201;
// --- Module-level state --- // --- Module-level state ---
@ -212,7 +213,7 @@ function sanitizeTeamTaskChangeSummaryRequests(requests: unknown): TeamTaskChang
const sanitizedRequests: TeamTaskChangeSummaryRequest[] = []; const sanitizedRequests: TeamTaskChangeSummaryRequest[] = [];
const seenTaskIds = new Set<string>(); const seenTaskIds = new Set<string>();
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) { if (sanitizedRequests.length >= TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT) {
break; break;
} }

View file

@ -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_ATTRIBUTION_MODE = 'strict-delivery' as const;
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const; const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const;
const OPEN_CODE_MAX_DISCOVERED_LANES = 500; 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_LIMIT = 200;
const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3; const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3;
@ -228,14 +229,13 @@ export class ChangeExtractorService {
const ledgerResult = await this.readLedgerTaskChanges(resolvedInput); const ledgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (ledgerResult) { if (ledgerResult) {
await this.recordTaskChangePresence( const recoveredLedgerResult = await this.recoverWarningOnlyLedgerResult(
teamName, resolvedInput,
taskId,
taskMeta,
effectiveOptions,
ledgerResult ledgerResult
); );
return ledgerResult; const result = recoveredLedgerResult ?? ledgerResult;
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
return result;
} }
const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput); const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput);
@ -339,7 +339,9 @@ export class ChangeExtractorService {
const inputRequests = Array.isArray(requests) ? requests : []; const inputRequests = Array.isArray(requests) ? requests : [];
const seenTaskIds = new Set<string>(); const seenTaskIds = new Set<string>();
const uniqueRequests: TeamTaskChangeSummaryRequest[] = []; 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') { if (!request || typeof request !== 'object') {
continue; continue;
} }
@ -349,6 +351,9 @@ export class ChangeExtractorService {
} }
seenTaskIds.add(taskId); seenTaskIds.add(taskId);
uniqueRequests.push({ ...request, 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 cappedRequests = uniqueRequests.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT);
const items: TeamTaskChangeSummaryItem[] = cappedRequests.map((request) => ({ const items: TeamTaskChangeSummaryItem[] = cappedRequests.map((request) => ({
@ -391,7 +396,10 @@ export class ChangeExtractorService {
teamName, teamName,
items, items,
computedAt: new Date().toISOString(), 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<TaskChangeSetV2 | null> {
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<boolean> {
const providerId = await this.resolveTaskOwnerProviderId(input);
return providerId === 'codex';
}
private async resolveTaskOwnerProviderId(
input: ResolvedTaskChangeComputeInput
): Promise<string | null> {
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<string>();
for (const group of groups) {
for (const warning of group) {
const trimmed = warning.trim();
if (trimmed) warnings.add(trimmed);
}
}
return [...warnings];
}
private async tryBackfillOpenCodeLedger( private async tryBackfillOpenCodeLedger(
input: ResolvedTaskChangeComputeInput input: ResolvedTaskChangeComputeInput
): Promise<OpenCodeBackfillAttempt> { ): Promise<OpenCodeBackfillAttempt> {

View file

@ -2312,6 +2312,9 @@ export class TeamDataService {
toolSummary: enrichedRequest.toolSummary, toolSummary: enrichedRequest.toolSummary,
toolCalls: enrichedRequest.toolCalls, toolCalls: enrichedRequest.toolCalls,
messageKind: enrichedRequest.messageKind, messageKind: enrichedRequest.messageKind,
workSyncIntent: enrichedRequest.workSyncIntent,
workSyncIntentKey: enrichedRequest.workSyncIntentKey,
workSyncReviewRequestEventIds: enrichedRequest.workSyncReviewRequestEventIds,
slashCommand: enrichedRequest.slashCommand, slashCommand: enrichedRequest.slashCommand,
commandOutput: enrichedRequest.commandOutput, commandOutput: enrichedRequest.commandOutput,
taskRefs: enrichedRequest.taskRefs, taskRefs: enrichedRequest.taskRefs,

View file

@ -148,6 +148,17 @@ export class TeamInboxReader {
: row.messageKind === 'default' : row.messageKind === 'default'
? 'default' ? 'default'
: undefined, : 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: slashCommand:
row.slashCommand && row.slashCommand &&
typeof row.slashCommand === 'object' && typeof row.slashCommand === 'object' &&

View file

@ -72,6 +72,11 @@ export class TeamInboxWriter {
...(request.toolSummary && { toolSummary: request.toolSummary }), ...(request.toolSummary && { toolSummary: request.toolSummary }),
...(request.toolCalls && { toolCalls: request.toolCalls }), ...(request.toolCalls && { toolCalls: request.toolCalls }),
...(request.messageKind && { messageKind: request.messageKind }), ...(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.slashCommand && { slashCommand: request.slashCommand }),
...(request.commandOutput && { commandOutput: request.commandOutput }), ...(request.commandOutput && { commandOutput: request.commandOutput }),
}; };

View file

@ -1,11 +1,11 @@
import { import {
AgentAttachmentError,
buildClaudeAttachmentDeliveryParts, buildClaudeAttachmentDeliveryParts,
buildCodexNativeAttachmentDeliveryParts, buildCodexNativeAttachmentDeliveryParts,
buildOpenCodeAttachmentDeliveryParts, buildOpenCodeAttachmentDeliveryParts,
type CodexNativeImageArgPart, type CodexNativeImageArgPart,
type OpenCodeFilePart, type OpenCodeFilePart,
} from '@features/agent-attachments/main'; } from '@features/agent-attachments/main';
import { AgentAttachmentError } from '@features/agent-attachments/core/domain';
import { import {
resolveAnthropicFastMode, resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection, resolveAnthropicRuntimeSelection,
@ -74,9 +74,9 @@ import {
} from '@shared/constants/crossTeam'; } from '@shared/constants/crossTeam';
import { getMemberColorByName } from '@shared/constants/memberColors'; import { getMemberColorByName } from '@shared/constants/memberColors';
import { import {
DEFAULT_TOOL_APPROVAL_SETTINGS,
type AttachmentMeta, type AttachmentMeta,
type AttachmentPayload, type AttachmentPayload,
DEFAULT_TOOL_APPROVAL_SETTINGS,
} from '@shared/types/team'; } from '@shared/types/team';
import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
@ -203,8 +203,8 @@ import {
decideOpenCodeRuntimeDeliveryAdvisory, decideOpenCodeRuntimeDeliveryAdvisory,
isDeferredGenericOpenCodeRuntimeDeliveryReason, isDeferredGenericOpenCodeRuntimeDeliveryReason,
isPotentialOpenCodeRuntimeDeliveryError, isPotentialOpenCodeRuntimeDeliveryError,
toOpenCodeRuntimeDeliveryUserVisibleImpact,
type OpenCodeRuntimeDeliveryAdvisoryDecision, type OpenCodeRuntimeDeliveryAdvisoryDecision,
toOpenCodeRuntimeDeliveryUserVisibleImpact,
} from './opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy'; } from './opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy';
import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
import { import {
@ -285,6 +285,7 @@ import {
buildDesktopTeammateModeCliArgs, buildDesktopTeammateModeCliArgs,
resolveDesktopTeammateModeDecision, resolveDesktopTeammateModeDecision,
} from './runtimeTeammateMode'; } from './runtimeTeammateMode';
import { TeamAttachmentStore } from './TeamAttachmentStore';
import { import {
choosePreferredLaunchSnapshot, choosePreferredLaunchSnapshot,
clearBootstrapState, clearBootstrapState,
@ -293,9 +294,9 @@ import {
readBootstrapRuntimeState, readBootstrapRuntimeState,
} from './TeamBootstrapStateReader'; } from './TeamBootstrapStateReader';
import { TeamConfigReader } from './TeamConfigReader'; import { TeamConfigReader } from './TeamConfigReader';
import { TeamAttachmentStore } from './TeamAttachmentStore';
import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxReader } from './TeamInboxReader';
import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamInboxWriter } from './TeamInboxWriter';
import { writeTeamLaunchFailureArtifactPack } from './TeamLaunchFailureArtifactPack';
import { import {
createPersistedLaunchSnapshot, createPersistedLaunchSnapshot,
deriveTeamLaunchAggregateState, deriveTeamLaunchAggregateState,
@ -304,7 +305,6 @@ import {
snapshotFromRuntimeMemberStatuses, snapshotFromRuntimeMemberStatuses,
snapshotToMemberSpawnStatuses, snapshotToMemberSpawnStatuses,
} from './TeamLaunchStateEvaluator'; } from './TeamLaunchStateEvaluator';
import { writeTeamLaunchFailureArtifactPack } from './TeamLaunchFailureArtifactPack';
import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamLaunchStateStore } from './TeamLaunchStateStore';
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
@ -5406,7 +5406,7 @@ interface LiveInboxRelayResult {
interface OpenCodeMemberInboxRelayOptions { interface OpenCodeMemberInboxRelayOptions {
onlyMessageId?: string; onlyMessageId?: string;
source?: 'watcher' | 'ui-send' | 'manual' | 'watchdog'; source?: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup';
deliveryMetadata?: { deliveryMetadata?: {
replyRecipient?: string; replyRecipient?: string;
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
@ -6967,7 +6967,10 @@ export class TeamProvisioningService {
const normalized = this.normalizeOpenCodeObservedToolName(toolName); const normalized = this.normalizeOpenCodeObservedToolName(toolName);
if ( if (
ledgerRecord?.messageKind === 'member_work_sync_nudge' && 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; return true;
} }
@ -7195,6 +7198,7 @@ export class TeamProvisioningService {
inboxMessageId: record.inboxMessageId, inboxMessageId: record.inboxMessageId,
replyRecipient: record.replyRecipient, replyRecipient: record.replyRecipient,
messageKind: record.messageKind, messageKind: record.messageKind,
workSyncIntent: record.workSyncIntent,
actionMode: record.actionMode, actionMode: record.actionMode,
taskRefs: record.taskRefs, taskRefs: record.taskRefs,
status: record.status, status: record.status,
@ -7875,6 +7879,8 @@ export class TeamProvisioningService {
replyRecipient?: string | null; replyRecipient?: string | null;
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
messageKind?: OpenCodeTeamRuntimeMessageInput['messageKind']; messageKind?: OpenCodeTeamRuntimeMessageInput['messageKind'];
workSyncIntent?: OpenCodeTeamRuntimeMessageInput['workSyncIntent'];
workSyncReviewRequestEventIds?: string[];
taskRefs?: TaskRef[]; taskRefs?: TaskRef[];
promptAccepted: boolean; promptAccepted: boolean;
visibleReply?: OpenCodeVisibleReplyProof | null; visibleReply?: OpenCodeVisibleReplyProof | null;
@ -7922,6 +7928,8 @@ export class TeamProvisioningService {
replyRecipient: input.replyRecipient ?? undefined, replyRecipient: input.replyRecipient ?? undefined,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind, messageKind: input.messageKind,
workSyncIntent: input.workSyncIntent,
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
prePromptCursor: ledgerRecord.prePromptCursor, prePromptCursor: ledgerRecord.prePromptCursor,
}); });
@ -8424,34 +8432,43 @@ export class TeamProvisioningService {
}> { }> {
const memberKey = record.memberName.trim().toLowerCase(); const memberKey = record.memberName.trim().toLowerCase();
let recordsForMember: OpenCodePromptDeliveryLedgerRecord[] = [record]; let recordsForMember: OpenCodePromptDeliveryLedgerRecord[] = [record];
let ledgerReadSucceeded = false;
try { try {
const laneRecords = await this.createOpenCodePromptDeliveryLedger( const laneRecords = await this.createOpenCodePromptDeliveryLedger(
record.teamName, record.teamName,
record.laneId record.laneId
).list(); ).list();
ledgerReadSucceeded = true;
recordsForMember = laneRecords.filter( recordsForMember = laneRecords.filter(
(candidate) => candidate.memberName.trim().toLowerCase() === memberKey (candidate) => candidate.memberName.trim().toLowerCase() === memberKey
); );
} catch { } catch {
recordsForMember = [record]; 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<string, readonly OpenCodePromptDeliveryLedgerRecord[]>([ const recordsByMember = new Map<string, readonly OpenCodePromptDeliveryLedgerRecord[]>([
[memberKey, recordsForMember.length > 0 ? recordsForMember : [latestRecord]], [memberKey, recordsForMember.length > 0 ? recordsForMember : [recordForDecision]],
]); ]);
const activeMemberKeys = new Set([memberKey]); const activeMemberKeys = new Set([memberKey]);
const proofIndex = await this.openCodeRuntimeDeliveryProofReader const proofIndex = await this.openCodeRuntimeDeliveryProofReader
.readProofIndex({ .readProofIndex({
teamName: latestRecord.teamName, teamName: recordForDecision.teamName,
activeMemberKeys, activeMemberKeys,
recordsByMember, recordsByMember,
}) })
.catch(() => null); .catch(() => null);
return { return {
record: latestRecord, record: recordForDecision,
decision: decideOpenCodeRuntimeDeliveryAdvisory({ decision: decideOpenCodeRuntimeDeliveryAdvisory({
record: latestRecord, record: recordForDecision,
proof: proofIndex?.getSnapshot(latestRecord.memberName, latestRecord), proof: proofIndex?.getSnapshot(recordForDecision.memberName, recordForDecision),
}), }),
}; };
} }
@ -8592,13 +8609,14 @@ export class TeamProvisioningService {
selectOpenCodeRuntimeDeliveryReason(record) ?? selectOpenCodeRuntimeDeliveryReason(record) ??
record.responseState ?? record.responseState ??
record.status; record.status;
const action = decision ? `${decision.action}:${decision.severity ?? 'none'}` : 'record:none';
const normalized = reason const normalized = reason
.toLowerCase() .toLowerCase()
.replace(/https?:\/\/\S+/g, '') .replace(/https?:\/\/\S+/g, '')
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
.slice(0, 96); .slice(0, 96);
return normalized || 'unknown'; return `${action}:${normalized || 'unknown'}`;
} }
private scheduleOpenCodeRuntimeDeliveryAdvisoryReview( private scheduleOpenCodeRuntimeDeliveryAdvisoryReview(
@ -8773,6 +8791,7 @@ export class TeamProvisioningService {
replyRecipient, replyRecipient,
actionMode: message.actionMode ?? null, actionMode: message.actionMode ?? null,
messageKind: message.messageKind ?? null, messageKind: message.messageKind ?? null,
workSyncIntent: message.workSyncIntent ?? null,
taskRefs: message.taskRefs ?? [], taskRefs: message.taskRefs ?? [],
payloadHash: hashOpenCodePromptDeliveryPayload({ payloadHash: hashOpenCodePromptDeliveryPayload({
text: message.text, text: message.text,
@ -8817,6 +8836,8 @@ export class TeamProvisioningService {
replyRecipient?: string; replyRecipient?: string;
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
messageKind?: InboxMessage['messageKind']; messageKind?: InboxMessage['messageKind'];
workSyncIntent?: InboxMessage['workSyncIntent'];
workSyncReviewRequestEventIds?: string[];
taskRefs?: TaskRef[]; taskRefs?: TaskRef[];
attachments?: AttachmentPayload[]; attachments?: AttachmentPayload[];
source?: OpenCodeMemberInboxRelayOptions['source']; source?: OpenCodeMemberInboxRelayOptions['source'];
@ -9042,6 +9063,8 @@ export class TeamProvisioningService {
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind, messageKind: input.messageKind,
workSyncIntent: input.workSyncIntent,
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
}); });
await this.rememberOpenCodeRuntimePidFromBridge({ await this.rememberOpenCodeRuntimePidFromBridge({
@ -9149,6 +9172,7 @@ export class TeamProvisioningService {
replyRecipient: input.replyRecipient ?? 'user', replyRecipient: input.replyRecipient ?? 'user',
actionMode: input.actionMode ?? null, actionMode: input.actionMode ?? null,
messageKind: input.messageKind ?? null, messageKind: input.messageKind ?? null,
workSyncIntent: input.workSyncIntent ?? null,
taskRefs: input.taskRefs ?? [], taskRefs: input.taskRefs ?? [],
payloadHash: hashOpenCodePromptDeliveryPayload({ payloadHash: hashOpenCodePromptDeliveryPayload({
text: input.text, text: input.text,
@ -9288,6 +9312,8 @@ export class TeamProvisioningService {
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind, messageKind: input.messageKind,
workSyncIntent: input.workSyncIntent,
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
prePromptCursor: ledgerRecord.prePromptCursor, prePromptCursor: ledgerRecord.prePromptCursor,
}); });
@ -9439,6 +9465,8 @@ export class TeamProvisioningService {
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind, messageKind: input.messageKind,
workSyncIntent: input.workSyncIntent,
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
}); });
await this.rememberOpenCodeRuntimePidFromBridge({ await this.rememberOpenCodeRuntimePidFromBridge({
@ -9497,6 +9525,8 @@ export class TeamProvisioningService {
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind, messageKind: input.messageKind,
workSyncIntent: input.workSyncIntent,
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
promptAccepted, promptAccepted,
visibleReply: proof.visibleReply, visibleReply: proof.visibleReply,
@ -11019,6 +11049,9 @@ export class TeamProvisioningService {
toolSummary: message.toolSummary, toolSummary: message.toolSummary,
toolCalls: message.toolCalls, toolCalls: message.toolCalls,
messageKind: message.messageKind, messageKind: message.messageKind,
workSyncIntent: message.workSyncIntent,
workSyncIntentKey: message.workSyncIntentKey,
workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds,
slashCommand: message.slashCommand, slashCommand: message.slashCommand,
commandOutput: message.commandOutput, commandOutput: message.commandOutput,
}); });
@ -11050,6 +11083,9 @@ export class TeamProvisioningService {
toolSummary: message.toolSummary, toolSummary: message.toolSummary,
toolCalls: message.toolCalls, toolCalls: message.toolCalls,
messageKind: message.messageKind, messageKind: message.messageKind,
workSyncIntent: message.workSyncIntent,
workSyncIntentKey: message.workSyncIntentKey,
workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds,
slashCommand: message.slashCommand, slashCommand: message.slashCommand,
commandOutput: message.commandOutput, commandOutput: message.commandOutput,
}); });
@ -21278,6 +21314,7 @@ export class TeamProvisioningService {
replyRecipient: effectiveReplyRecipient, replyRecipient: effectiveReplyRecipient,
actionMode: effectiveActionMode ?? null, actionMode: effectiveActionMode ?? null,
messageKind: message.messageKind ?? null, messageKind: message.messageKind ?? null,
workSyncIntent: message.workSyncIntent ?? null,
taskRefs: effectiveTaskRefs, taskRefs: effectiveTaskRefs,
payloadHash: hashOpenCodePromptDeliveryPayload({ payloadHash: hashOpenCodePromptDeliveryPayload({
text: message.text, text: message.text,
@ -21332,6 +21369,8 @@ export class TeamProvisioningService {
replyRecipient: effectiveReplyRecipient, replyRecipient: effectiveReplyRecipient,
actionMode: effectiveActionMode ?? undefined, actionMode: effectiveActionMode ?? undefined,
messageKind: message.messageKind, messageKind: message.messageKind,
workSyncIntent: message.workSyncIntent,
workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds,
taskRefs: effectiveTaskRefs, taskRefs: effectiveTaskRefs,
attachments: attachmentPayloads.attachments, attachments: attachmentPayloads.attachments,
source: effectiveSource, source: effectiveSource,

View file

@ -6,7 +6,7 @@ import type {
OpenCodeDeliveryResponseState, OpenCodeDeliveryResponseState,
OpenCodeDeliveryVisibleReplyCorrelation, OpenCodeDeliveryVisibleReplyCorrelation,
} from '../bridge/OpenCodeBridgeCommandContract'; } 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_LEDGER_SCHEMA_VERSION = 1;
export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
@ -31,8 +31,9 @@ export interface OpenCodePromptDeliveryLedgerRecord {
runtimeSessionId: string | null; runtimeSessionId: string | null;
inboxMessageId: string; inboxMessageId: string;
inboxTimestamp: string; inboxTimestamp: string;
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog'; source: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup';
messageKind: InboxMessageKind | null; messageKind: InboxMessageKind | null;
workSyncIntent?: InboxMessage['workSyncIntent'] | null;
replyRecipient: string; replyRecipient: string;
actionMode: AgentActionMode | null; actionMode: AgentActionMode | null;
taskRefs: TaskRef[]; taskRefs: TaskRef[];
@ -99,6 +100,7 @@ const OPENCODE_PROMPT_DELIVERY_SOURCES = new Set<OpenCodePromptDeliveryLedgerRec
'ui-send', 'ui-send',
'manual', 'manual',
'watchdog', 'watchdog',
'member-work-sync-review-pickup',
]); ]);
const OPENCODE_DELIVERY_VISIBLE_REPLY_CORRELATIONS = const OPENCODE_DELIVERY_VISIBLE_REPLY_CORRELATIONS =
@ -119,6 +121,7 @@ export interface EnsureOpenCodePromptDeliveryInput {
inboxTimestamp: string; inboxTimestamp: string;
source: OpenCodePromptDeliveryLedgerRecord['source']; source: OpenCodePromptDeliveryLedgerRecord['source'];
messageKind?: InboxMessageKind | null; messageKind?: InboxMessageKind | null;
workSyncIntent?: InboxMessage['workSyncIntent'] | null;
replyRecipient: string; replyRecipient: string;
actionMode?: AgentActionMode | null; actionMode?: AgentActionMode | null;
taskRefs?: TaskRef[]; taskRefs?: TaskRef[];
@ -181,6 +184,16 @@ export class OpenCodePromptDeliveryLedgerStore {
const updated: OpenCodePromptDeliveryLedgerRecord = { const updated: OpenCodePromptDeliveryLedgerRecord = {
...existing, ...existing,
messageKind: input.messageKind, messageKind: input.messageKind,
...(input.workSyncIntent ? { workSyncIntent: input.workSyncIntent } : {}),
updatedAt: input.now,
};
result = updated;
return records.map((record) => (record.id === existing.id ? updated : record));
}
if (existing.workSyncIntent == null && input.workSyncIntent) {
const updated: OpenCodePromptDeliveryLedgerRecord = {
...existing,
workSyncIntent: input.workSyncIntent,
updatedAt: input.now, updatedAt: input.now,
}; };
result = updated; result = updated;
@ -201,6 +214,7 @@ export class OpenCodePromptDeliveryLedgerStore {
inboxTimestamp: input.inboxTimestamp, inboxTimestamp: input.inboxTimestamp,
source: input.source, source: input.source,
messageKind: input.messageKind ?? null, messageKind: input.messageKind ?? null,
workSyncIntent: input.workSyncIntent ?? null,
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode ?? null, actionMode: input.actionMode ?? null,
taskRefs: input.taskRefs ?? [], taskRefs: input.taskRefs ?? [],

View file

@ -1,6 +1,6 @@
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract'; import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
import type { OpenCodePromptDeliveryStatus } from './OpenCodePromptDeliveryLedger'; 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 = export type OpenCodePromptDeliveryRepairKind =
| 'none' | 'none'
@ -26,6 +26,7 @@ export interface OpenCodePromptDeliveryRepairInput {
inboxMessageId: string; inboxMessageId: string;
replyRecipient: string; replyRecipient: string;
messageKind: InboxMessageKind | null; messageKind: InboxMessageKind | null;
workSyncIntent?: InboxMessage['workSyncIntent'] | null;
actionMode: AgentActionMode | null; actionMode: AgentActionMode | null;
taskRefs: TaskRef[]; taskRefs: TaskRef[];
status: OpenCodePromptDeliveryStatus; status: OpenCodePromptDeliveryStatus;
@ -52,6 +53,12 @@ const SIDE_EFFECT_TOOL_NAMES = new Set([
'multi_edit', 'multi_edit',
]); ]);
const REVIEW_WORKFLOW_TOOL_NAMES = new Set([
'review_start',
'review_approve',
'review_request_changes',
]);
function none(reason: string): OpenCodePromptDeliveryRepairDecision { function none(reason: string): OpenCodePromptDeliveryRepairDecision {
return { kind: 'none', retryable: false, controlText: null, reason }; return { kind: 'none', retryable: false, controlText: null, reason };
} }
@ -96,7 +103,11 @@ function hasTool(tools: Set<string>, toolName: string): boolean {
function hasTaskTool(tools: Set<string>): boolean { function hasTaskTool(tools: Set<string>): boolean {
for (const tool of tools) { 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; return true;
} }
} }
@ -137,6 +148,16 @@ function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): stri
function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] { function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
const taskIds = taskIdList(input.taskRefs); 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 [ return [
'This is a member-work-sync control message. A plain acknowledgement is not sufficient proof.', '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}".`, `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.', 'The app saw the prompt but did not observe assistant response proof.',
'You must not end this turn empty.', 'You must not end this turn empty.',
input.messageKind === 'member_work_sync_nudge' 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.`, : `Send a concrete reply using message_send with relayOfMessageId="${input.inboxMessageId}", or provide a concrete plain-text answer only if message_send is unavailable.`,
]; ];
} }

View file

@ -28,6 +28,7 @@ import type {
} from './TeamRuntimeAdapter'; } from './TeamRuntimeAdapter';
import type { import type {
AgentActionMode, AgentActionMode,
InboxMessage,
InboxMessageKind, InboxMessageKind,
OpenCodeAppManagedBootstrapCandidate, OpenCodeAppManagedBootstrapCandidate,
TaskRef, TaskRef,
@ -65,6 +66,8 @@ export interface OpenCodeTeamRuntimeMessageInput {
replyRecipient?: string; replyRecipient?: string;
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
messageKind?: InboxMessageKind; messageKind?: InboxMessageKind;
workSyncIntent?: InboxMessage['workSyncIntent'];
workSyncReviewRequestEventIds?: string[];
taskRefs?: TaskRef[]; taskRefs?: TaskRef[];
bootstrapCheckinRetry?: { bootstrapCheckinRetry?: {
runtimeSessionId: string; runtimeSessionId: string;
@ -900,10 +903,15 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
memberName: input.memberName, memberName: input.memberName,
inboundMessageId: input.messageId, inboundMessageId: input.messageId,
...(input.messageKind ? { messageKind: input.messageKind } : {}), ...(input.messageKind ? { messageKind: input.messageKind } : {}),
...(input.workSyncIntent ? { workSyncIntent: input.workSyncIntent } : {}),
...(input.workSyncReviewRequestEventIds?.length
? { workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds }
: {}),
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
}) })
: null; : null;
const isWorkSyncNudge = input.messageKind === 'member_work_sync_nudge'; const isWorkSyncNudge = input.messageKind === 'member_work_sync_nudge';
const isReviewPickupNudge = isWorkSyncNudge && input.workSyncIntent === 'review_pickup';
const taskIds = const taskIds =
input.taskRefs input.taskRefs
?.map((ref) => ref.taskId?.trim()) ?.map((ref) => ref.taskId?.trim())
@ -912,33 +920,44 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
// message_send reply here causes false delivery failures, so accept the // message_send reply here causes false delivery failures, so accept the
// dedicated member_work_sync_report proof path while keeping normal user // dedicated member_work_sync_report proof path while keeping normal user
// messages on the visible reply contract. // messages on the visible reply contract.
const responseInstructions = isWorkSyncNudge const responseInstructions = isReviewPickupNudge
? [ ? [
'This delivered app message is a member-work-sync nudge.', 'This delivered app message is a targeted member-work-sync review pickup 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.', '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.',
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with teamName="${input.teamName}" and memberName="${input.memberName}".`, 'Do not mark the review complete from this prompt alone.',
`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".`, '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.',
taskIds.length `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.`,
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null,
: null,
`Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`, `Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`,
'Do not reply only with acknowledgement.', 'Do not reply only with acknowledgement.',
] ]
: [ : isWorkSyncNudge
'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.`, 'This delivered app message is a member-work-sync nudge.',
'Include source="runtime_delivery" in that message_send call.', '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.',
input.messageId `Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with teamName="${input.teamName}" and memberName="${input.memberName}".`,
? `Include relayOfMessageId="${input.messageId}" in that message_send call.` `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".`,
: null, taskIds.length
input.taskRefs?.length ? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
? `If taskRefs are present in <opencode_delivery_context>, include taskRefs exactly as provided in that message_send call: ${JSON.stringify(input.taskRefs)}.` : null,
: null, `Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`,
'If message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.', 'Do not reply only with acknowledgement.',
'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.', '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 <opencode_delivery_context>, 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 [ return [
'<opencode_app_message_delivery>', '<opencode_app_message_delivery>',

View file

@ -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 { 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 { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { AlertTriangle, FileDiff, GitCompareArrows, Loader2, RefreshCw } from 'lucide-react'; import { AlertTriangle, FileDiff, GitCompareArrows, Loader2, RefreshCw } from 'lucide-react';
import { FileIcon } from './editor/FileIcon'; import { FileIcon } from './editor/FileIcon';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { import {
buildTeamChangeRequestPlan,
buildTeamChangesTasksFingerprint,
getTeamChangeTaskTimeMs, getTeamChangeTaskTimeMs,
TEAM_CHANGES_MAX_RENDERED_FILE_ROWS, TEAM_CHANGES_MAX_RENDERED_FILE_ROWS,
} from './teamChangesRequestPlan'; } from './teamChangesRequestPlan';
import { type TeamChangeSummaryState, useTeamChangesSummaries } from './useTeamChangesSummaries';
import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types'; import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types';
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
interface TeamChangesSectionProps { interface TeamChangesSectionProps {
teamName: string; teamName: string;
tasks: TeamTaskWithKanban[]; tasks: TeamTaskWithKanban[];
onViewChanges: (taskId: string, filePath?: string) => void; onViewChanges: (taskId: string, filePath?: string) => void;
} }
interface TeamChangeSummaryState { interface RenderedTeamChangeSummary {
taskId: string; summary: TeamChangeSummaryState;
changeSet: TaskChangeSetV2 | null; task: TeamTaskWithKanban;
error?: string; visibleFiles: FileChangeSummary[];
} fileBudget: number;
interface TeamChangeStats {
eligibleCount: number;
requestedCount: number;
deferredCount: number;
}
interface TeamChangesLoadOptions {
forceFresh?: boolean;
showSpinner?: boolean;
preserveOnError?: boolean;
} }
function getTaskChangeContributors( function getTaskChangeContributors(
@ -71,7 +54,7 @@ function getTaskChangeContributors(
function getVisibleFileName(file: FileChangeSummary): string { function getVisibleFileName(file: FileChangeSummary): string {
const value = file.relativePath || file.filePath; const value = file.relativePath || file.filePath;
return value.split('/').pop() ?? value; return value.split(/[\\/]/).pop() ?? value;
} }
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined { function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
@ -86,31 +69,15 @@ export const TeamChangesSection = memo(function TeamChangesSection({
tasks, tasks,
onViewChanges, onViewChanges,
}: TeamChangesSectionProps): React.JSX.Element { }: TeamChangesSectionProps): React.JSX.Element {
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
const [sectionOpen, setSectionOpen] = useState(false); const [sectionOpen, setSectionOpen] = useState(false);
const [summariesByTaskId, setSummariesByTaskId] = useState< const { summariesByTaskId, stats, loading, refreshing, error, refresh } = useTeamChangesSummaries(
Record<string, TeamChangeSummaryState> {
>({}); teamName,
const [stats, setStats] = useState<TeamChangeStats>({ tasks,
eligibleCount: 0, sectionOpen,
requestedCount: 0, }
deferredCount: 0, );
});
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queuedRefreshTick, setQueuedRefreshTick] = useState(0);
const hasLoadedRef = useRef(false);
const requestSeqRef = useRef(0);
const activeRequestSeqRef = useRef<number | null>(null);
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
const sectionOpenRef = useRef(sectionOpen);
const unknownScanCursorRef = useRef(0);
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
const tasksFingerprint = useMemo(() => buildTeamChangesTasksFingerprint(tasks), [tasks]);
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
sectionOpenRef.current = sectionOpen;
const visibleSummaries = useMemo(() => { const visibleSummaries = useMemo(() => {
return Object.values(summariesByTaskId) 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 hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined; const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined;
const renderedSummaries = useMemo(() => {
const loadSummaries = useCallback( const entries: RenderedTeamChangeSummary[] = [];
async ({ let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
forceFresh = false, for (const entry of visibleSummaries) {
showSpinner = false, const files = entry.summary.changeSet?.files ?? [];
preserveOnError = true, const fileBudget = Math.max(0, remainingFileRows);
}: TeamChangesLoadOptions = {}): Promise<void> => { const visibleFiles = files.slice(0, fileBudget);
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) { entries.push({ ...entry, visibleFiles, fileBudget });
const previous = queuedRefreshOptionsRef.current; remainingFileRows -= visibleFiles.length;
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<string, TeamChangeSummaryState> = {};
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);
} }
}, [sectionOpen]); return entries;
}, [visibleSummaries]);
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 ( return (
<CollapsibleTeamSection <CollapsibleTeamSection
@ -343,7 +133,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
className="pointer-events-auto rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-section-hover)] hover:text-[var(--color-text)] disabled:opacity-50" className="pointer-events-auto rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-section-hover)] hover:text-[var(--color-text)] disabled:opacity-50"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
handleRefresh(); refresh();
}} }}
disabled={loading || refreshing} disabled={loading || refreshing}
aria-label="Refresh team changes" aria-label="Refresh team changes"
@ -370,12 +160,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({
) : visibleSummaries.length > 0 ? ( ) : visibleSummaries.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="max-h-[360px] space-y-2 overflow-y-auto pr-1"> <div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
{visibleSummaries.map(({ summary, task }) => { {renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
const changeSet = summary.changeSet; const changeSet = summary.changeSet;
const files = changeSet?.files ?? []; 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 contributors = getTaskChangeContributors(task, changeSet);
const contributorLabel = const contributorLabel =
contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned'; contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { import {
buildTeamChangeRequestPlan, buildTeamChangeRequestPlan,
buildTeamChangesTasksFingerprint, buildTeamChangesTasksFingerprint,
TEAM_CHANGES_MAX_REQUESTS,
TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, TEAM_CHANGES_UNKNOWN_SCAN_LIMIT,
} from '../teamChangesRequestPlan'; } from '../teamChangesRequestPlan';
@ -49,6 +50,41 @@ describe('buildTeamChangeRequestPlan', () => {
expect(plan.eligibleTaskIds.has('known-changed')).toBe(true); 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', () => { it('rotates unknown scans and preserves summary-only request options', () => {
const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) => const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) =>
task({ task({

View file

@ -72,6 +72,7 @@ import {
import { format, formatDistanceToNow } from 'date-fns'; import { format, formatDistanceToNow } from 'date-fns';
import { import {
AlignLeft, AlignLeft,
AlertTriangle,
ArrowLeftFromLine, ArrowLeftFromLine,
ArrowRightFromLine, ArrowRightFromLine,
Check, Check,
@ -166,6 +167,7 @@ export const TaskDetailDialog = ({
const [taskLogStreamCount, setTaskLogStreamCount] = useState<number | undefined>(undefined); const [taskLogStreamCount, setTaskLogStreamCount] = useState<number | undefined>(undefined);
const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [changesSectionOpen, setChangesSectionOpen] = useState(false);
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null); const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
const [taskChangesWarnings, setTaskChangesWarnings] = useState<string[]>([]);
const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesLoading, setTaskChangesLoading] = useState(false);
const [taskChangesError, setTaskChangesError] = useState<string | null>(null); const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null); const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null);
@ -235,6 +237,7 @@ export const TaskDetailDialog = ({
useEffect(() => { useEffect(() => {
setChangesSectionOpen(false); setChangesSectionOpen(false);
setTaskChangesFiles(null); setTaskChangesFiles(null);
setTaskChangesWarnings([]);
setTaskChangesLoading(false); setTaskChangesLoading(false);
setTaskChangesError(null); setTaskChangesError(null);
setLogsRefreshing(false); setLogsRefreshing(false);
@ -392,6 +395,7 @@ export const TaskDetailDialog = ({
const syncTaskChangeSummaryResult = useCallback( const syncTaskChangeSummaryResult = useCallback(
(data: TaskChangeSetV2 | null) => { (data: TaskChangeSetV2 | null) => {
setTaskChangesFiles(data?.files ?? null); setTaskChangesFiles(data?.files ?? null);
setTaskChangesWarnings(data?.warnings ?? []);
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
if (currentTask && taskChangeRequestOptions) { if (currentTask && taskChangeRequestOptions) {
recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence); recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence);
@ -441,6 +445,7 @@ export const TaskDetailDialog = ({
} }
if (!preserveFilesOnError) { if (!preserveFilesOnError) {
setTaskChangesFiles(null); setTaskChangesFiles(null);
setTaskChangesWarnings([]);
} }
setTaskChangesError( setTaskChangesError(
error instanceof Error ? error.message : 'Failed to load task changes summary' error instanceof Error ? error.message : 'Failed to load task changes summary'
@ -583,6 +588,14 @@ export const TaskDetailDialog = ({
setChangesSectionOpen(isOpen); 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 [taskDurationNowMs, setTaskDurationNowMs] = useState(() => Date.now());
const taskImplementationDuration = useMemo( const taskImplementationDuration = useMemo(
() => calculateTaskImplementationDuration(currentTask, taskDurationNowMs), () => calculateTaskImplementationDuration(currentTask, taskDurationNowMs),
@ -1186,9 +1199,7 @@ export const TaskDetailDialog = ({
key={`task-changes:${currentTask.id}`} key={`task-changes:${currentTask.id}`}
title="Changes" title="Changes"
icon={<FileDiff size={14} />} icon={<FileDiff size={14} />}
badge={ badge={taskChangesBadge}
!taskChangesLoading && taskChangesFiles ? taskChangesFiles.length : undefined
}
headerExtra={ headerExtra={
taskChangesLoading && !changesSectionOpen ? ( taskChangesLoading && !changesSectionOpen ? (
<Loader2 <Loader2
@ -1231,76 +1242,105 @@ export const TaskDetailDialog = ({
</div> </div>
) : taskChangesError ? ( ) : taskChangesError ? (
<p className="text-xs text-red-400">{taskChangesError}</p> <p className="text-xs text-red-400">{taskChangesError}</p>
) : taskChangesFiles && taskChangesFiles.length > 0 ? ( ) : taskChangesFiles ? (
<div className="max-h-[200px] space-y-0.5 overflow-y-auto"> <div className="space-y-2">
{taskChangesFiles.map((file) => ( {taskChangesWarnings.length > 0 ? (
<div <div className="space-y-1 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5">
key={file.filePath} {taskChangesWarnings.slice(0, 2).map((warning) => (
className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]" <div
> key={warning}
<FileIcon className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]"
fileName={file.relativePath.split('/').pop() ?? file.relativePath}
className="size-3.5"
/>
{onViewChanges ? (
<button
type="button"
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
> >
{file.relativePath} <AlertTriangle size={13} className="shrink-0" />
</button> <span className="min-w-0 truncate">{warning}</span>
) : ( </div>
<span className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)]"> ))}
{file.relativePath} {taskChangesWarnings.length > 2 ? (
</span> <p className="text-[10px] text-[var(--color-text-muted)]">
)} {taskChangesWarnings.length - 2} more warnings
<span className="flex shrink-0 items-center gap-1.5"> </p>
{file.linesAdded > 0 ? ( ) : null}
<span className="text-emerald-400">+{file.linesAdded}</span>
) : null}
{file.linesRemoved > 0 ? (
<span className="text-red-400">-{file.linesRemoved}</span>
) : null}
</span>
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{onViewChanges ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
<GitCompareArrows size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Review diff</TooltipContent>
</Tooltip>
) : null}
{onOpenInEditor ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => onOpenInEditor(file.filePath)}
>
<SquarePen size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Open in editor</TooltipContent>
</Tooltip>
) : null}
</span>
</div> </div>
))} ) : null}
{taskChangesFiles.length > 0 ? (
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
{taskChangesFiles.map((file) => (
<div
key={file.filePath}
className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
>
<FileIcon
fileName={file.relativePath.split('/').pop() ?? file.relativePath}
className="size-3.5"
/>
{onViewChanges ? (
<button
type="button"
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
{file.relativePath}
</button>
) : (
<span className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)]">
{file.relativePath}
</span>
)}
<span className="flex shrink-0 items-center gap-1.5">
{file.linesAdded > 0 ? (
<span className="text-emerald-400">+{file.linesAdded}</span>
) : null}
{file.linesRemoved > 0 ? (
<span className="text-red-400">-{file.linesRemoved}</span>
) : null}
</span>
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{onViewChanges ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
<GitCompareArrows size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Review diff</TooltipContent>
</Tooltip>
) : null}
{onOpenInEditor ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => onOpenInEditor(file.filePath)}
>
<SquarePen size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Open in editor</TooltipContent>
</Tooltip>
) : null}
</span>
</div>
))}
</div>
) : changesSectionOpen ? (
<p className="text-xs text-[var(--color-text-muted)]">
{taskChangesWarnings.length > 0
? 'No reviewable file changes recovered'
: 'No file changes recorded'}
</p>
) : null}
</div> </div>
) : changesSectionOpen ? ( ) : changesSectionOpen ? (
<p className="text-xs text-[var(--color-text-muted)]">No file changes recorded</p> <p className="text-xs text-[var(--color-text-muted)]">No file changes recorded</p>

View file

@ -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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -224,7 +224,7 @@ describe('KanbanTaskCard change badge', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.querySelector('[aria-label="Changes"]')).toBeNull(); expect(host.querySelector('[aria-label="Changes need attention"]')).not.toBeNull();
await act(async () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -263,14 +263,22 @@ export const KanbanTaskCard = memo(
const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim(); const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim();
const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0; 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 = ( const metaActions = (
<> <>
{canDisplay && task.changePresence === 'has_changes' ? ( {canOpenChanges ? (
<TaskActionIconButton <TaskActionIconButton
label="Changes" label={changesNeedAttention ? 'Changes need attention' : 'Changes'}
icon={<FileCode className="size-2.5" />} icon={<FileCode className="size-2.5" />}
variant="ghost" 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onViewChanges!(task.id); onViewChanges!(task.id);

View file

@ -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<string, TeamChangeSummaryState>;
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<string, TeamChangeSummaryState>
>({});
const [stats, setStats] = useState<TeamChangeStats>({
eligibleCount: 0,
requestedCount: 0,
deferredCount: 0,
});
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queuedRefreshTick, setQueuedRefreshTick] = useState(0);
const hasLoadedRef = useRef(false);
const mountedRef = useRef(true);
const requestSeqRef = useRef(0);
const activeRequestSeqRef = useRef<number | null>(null);
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
const sectionOpenRef = useRef(sectionOpen);
const unknownScanCursorRef = useRef(0);
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
const tasksFingerprint = useMemo(
() => (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<void> => {
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<string, TeamChangeSummaryState> = {};
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,
};
}

View file

@ -4581,8 +4581,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
); );
if (!status) return; if (!status) return;
if (statusMessageId !== normalizedMessageId) { if (statusMessageId !== normalizedMessageId) {
const blockerUserVisibleState = status.userVisibleImpact?.state;
const blockerStillChecking = const blockerStillChecking =
status.userVisibleImpact?.state === 'checking' || status.responsePending === true; blockerUserVisibleState !== undefined
? blockerUserVisibleState === 'checking'
: status.responsePending === true;
if (!blockerStillChecking) { if (!blockerStillChecking) {
const ownStatus = await unwrapIpc('team:getOpenCodeRuntimeDeliveryStatus', () => const ownStatus = await unwrapIpc('team:getOpenCodeRuntimeDeliveryStatus', () =>
api.teams.getOpenCodeRuntimeDeliveryStatus(teamName, normalizedMessageId) api.teams.getOpenCodeRuntimeDeliveryStatus(teamName, normalizedMessageId)

View file

@ -173,6 +173,9 @@ export function shouldClearPendingReplyForOpenCodeRuntimeDelivery(
return false; return false;
} }
const userVisibleState = runtimeDelivery.userVisibleImpact?.state; const userVisibleState = runtimeDelivery.userVisibleImpact?.state;
if (userVisibleState === 'none') {
return true;
}
if (userVisibleState === 'warning' || userVisibleState === 'error') { if (userVisibleState === 'warning' || userVisibleState === 'error') {
return true; return true;
} }

View file

@ -664,6 +664,12 @@ export interface InboxMessage {
toolCalls?: ToolCallMeta[]; toolCalls?: ToolCallMeta[];
/** Renderer-friendly semantic kind. Defaults to "default" when absent. */ /** Renderer-friendly semantic kind. Defaults to "default" when absent. */
messageKind?: InboxMessageKind; 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. */ /** Structured slash-command metadata for sent command rows. */
slashCommand?: SlashCommandMeta; slashCommand?: SlashCommandMeta;
/** Structured command-output metadata for session-derived result rows. */ /** Structured command-output metadata for session-derived result rows. */
@ -708,6 +714,9 @@ export interface SendMessageRequest {
toolSummary?: string; toolSummary?: string;
toolCalls?: ToolCallMeta[]; toolCalls?: ToolCallMeta[];
messageKind?: InboxMessageKind; messageKind?: InboxMessageKind;
workSyncIntent?: InboxMessage['workSyncIntent'];
workSyncIntentKey?: string;
workSyncReviewRequestEventIds?: string[];
slashCommand?: SlashCommandMeta; slashCommand?: SlashCommandMeta;
commandOutput?: CommandOutputMeta; commandOutput?: CommandOutputMeta;
} }

View file

@ -245,7 +245,242 @@ describe('buildActionableWorkAgenda', () => {
taskId: 'task-1', taskId: 'task-1',
kind: 'review', kind: 'review',
assignee: 'alice', 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'],
}); });
}); });

View file

@ -10,6 +10,8 @@ import {
type MemberWorkSyncAuditEvent, type MemberWorkSyncAuditEvent,
type MemberWorkSyncInboxNudgePort, type MemberWorkSyncInboxNudgePort,
type MemberWorkSyncOutboxStorePort, type MemberWorkSyncOutboxStorePort,
type MemberWorkSyncReviewPickupDeliveryPort,
type MemberWorkSyncReviewPickupEscalationPort,
type MemberWorkSyncStatusStorePort, type MemberWorkSyncStatusStorePort,
type MemberWorkSyncUseCaseDeps, type MemberWorkSyncUseCaseDeps,
} from '@features/member-work-sync/core/application'; } 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 { class MutableClock {
private current = new Date('2026-04-29T00:00:00.000Z'); private current = new Date('2026-04-29T00:00:00.000Z');
@ -185,6 +221,8 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
...current, ...current,
status: 'delivered', status: 'delivered',
deliveredMessageId: input.deliveredMessageId, deliveredMessageId: input.deliveredMessageId,
...(input.deliveryState ? { deliveryState: input.deliveryState } : {}),
...(input.deliveryDiagnostics ? { deliveryDiagnostics: input.deliveryDiagnostics } : {}),
updatedAt: input.nowIso, updatedAt: input.nowIso,
}); });
} }
@ -218,6 +256,26 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
item.updatedAt >= input.sinceIso item.updatedAt >= input.sinceIso
).length; ).length;
} }
async findDeliveredReviewPickupRequestEventIds(input: {
memberName: string;
reviewRequestEventIds: string[];
}): Promise<string[]> {
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 { class InMemoryInboxNudge implements MemberWorkSyncInboxNudgePort {
@ -242,6 +300,8 @@ function createDeps(options?: {
outboxStore?: MemberWorkSyncOutboxStorePort; outboxStore?: MemberWorkSyncOutboxStorePort;
inboxNudge?: MemberWorkSyncInboxNudgePort; inboxNudge?: MemberWorkSyncInboxNudgePort;
busySignal?: MemberWorkSyncUseCaseDeps['busySignal']; busySignal?: MemberWorkSyncUseCaseDeps['busySignal'];
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
}) { }) {
const clock = new MutableClock(); const clock = new MutableClock();
const store = new InMemoryStatusStore(); const store = new InMemoryStatusStore();
@ -272,6 +332,12 @@ function createDeps(options?: {
...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}), ...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}),
...(options?.inboxNudge ? { inboxNudge: options.inboxNudge } : {}), ...(options?.inboxNudge ? { inboxNudge: options.inboxNudge } : {}),
...(options?.busySignal ? { busySignal: options.busySignal } : {}), ...(options?.busySignal ? { busySignal: options.busySignal } : {}),
...(options?.reviewPickupDelivery
? { reviewPickupDelivery: options.reviewPickupDelivery }
: {}),
...(options?.reviewPickupEscalation
? { reviewPickupEscalation: options.reviewPickupEscalation }
: {}),
reportToken: { reportToken: {
create: async (input) => ({ create: async (input) => ({
token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`, 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'); 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 () => { it('rejects stale reports without turning app-side validation failures into pending intents', async () => {
const { auditEvents, deps, store } = createDeps(); const { auditEvents, deps, store } = createDeps();
const result = await new MemberWorkSyncReporter(deps).execute({ const result = await new MemberWorkSyncReporter(deps).execute({
@ -473,6 +559,178 @@ describe('MemberWorkSync use cases', () => {
expect(outbox.ensures).toEqual([]); 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<Parameters<MemberWorkSyncReviewPickupEscalationPort['escalate']>[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 () => { it('does not create outbox nudges from read-only diagnostics requests', async () => {
const outbox = new InMemoryOutboxStore(); const outbox = new InMemoryOutboxStore();
const { deps, store } = createDeps({ outboxStore: outbox }); 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<Parameters<MemberWorkSyncReviewPickupDeliveryPort['deliver']>[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<Parameters<MemberWorkSyncReviewPickupEscalationPort['escalate']>[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<Parameters<MemberWorkSyncReviewPickupEscalationPort['escalate']>[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 () => { it('recomputes agenda before dispatch and supersedes stale outbox fingerprints', async () => {
const outbox = new InMemoryOutboxStore(); const outbox = new InMemoryOutboxStore();
const inbox = new InMemoryInboxNudge(); const inbox = new InMemoryInboxNudge();

View file

@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
import { decideMemberWorkSyncNudgeActivation } from '@features/member-work-sync/core/application'; 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> = {}): MemberWorkSyncStatus { function status(overrides: Partial<MemberWorkSyncStatus> = {}): MemberWorkSyncStatus {
return { return {
@ -102,6 +105,164 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
).toEqual({ active: false, reason: 'phase2_not_ready' }); ).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', () => { it('does not activate when blocking safety metrics are present', () => {
expect( expect(
decideMemberWorkSyncNudgeActivation({ decideMemberWorkSyncNudgeActivation({

View file

@ -46,13 +46,16 @@ function makeStatus(overrides: Partial<MemberWorkSyncStatus>): MemberWorkSyncSta
}; };
} }
function makeNudgePayload(overrides: Partial<MemberWorkSyncNudgePayload> = {}): MemberWorkSyncNudgePayload { function makeNudgePayload(
overrides: Partial<MemberWorkSyncNudgePayload> = {}
): MemberWorkSyncNudgePayload {
return { return {
from: 'system', from: 'system',
to: 'bob', to: 'bob',
messageKind: 'member_work_sync_nudge', messageKind: 'member_work_sync_nudge',
source: 'member-work-sync', source: 'member-work-sync',
actionMode: 'do', actionMode: 'do',
workSyncIntent: 'agenda_sync',
text: 'Work sync check: continue the current task or report a blocker.', text: 'Work sync check: continue the current task or report a blocker.',
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
...overrides, ...overrides,
@ -129,7 +132,9 @@ describe('JsonMemberWorkSyncStore', () => {
}); });
it('prefers member-scoped v2 status over legacy v1 status', async () => { 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'); const legacyStatusPath = join(root, 'team-a', '.member-work-sync', 'status.json');
await mkdir(join(root, 'team-a', '.member-work-sync'), { recursive: true }); await mkdir(join(root, 'team-a', '.member-work-sync'), { recursive: true });
@ -252,9 +257,9 @@ describe('JsonMemberWorkSyncStore', () => {
'utf8' 'utf8'
) )
); );
expect(Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)).toEqual([ expect(
'tom', 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 () => { it('repairs a partially missing pending-report index route from member-scoped report files', async () => {
@ -491,7 +496,10 @@ describe('JsonMemberWorkSyncStore', () => {
attemptGeneration: 2, attemptGeneration: 2,
}); });
const index = JSON.parse( 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({ expect(index.items[input.id]).toMatchObject({
memberName: 'bob', memberName: 'bob',
@ -635,6 +643,107 @@ describe('JsonMemberWorkSyncStore', () => {
expect(repaired.items[bobInput.id]).toMatchObject({ memberName: 'bob', status: 'delivered' }); 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 () => { it('repairs stale due outbox index routes before persisting claim results', async () => {
const bobInput = { const bobInput = {
id: 'member-work-sync:team-a:bob:agenda:v1:abc', id: 'member-work-sync:team-a:bob:agenda:v1:abc',
@ -667,11 +776,14 @@ describe('JsonMemberWorkSyncStore', () => {
}); });
expect(claimed.map((item) => item.memberName)).toEqual(['tom']); expect(claimed.map((item) => item.memberName)).toEqual(['tom']);
const repaired = JSON.parse( 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([ expect(
'tom', Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)
]); ).toEqual(['tom']);
}); });
it('repairs partially missing due outbox index routes before claiming', async () => { it('repairs partially missing due outbox index routes before claiming', async () => {
@ -755,8 +867,9 @@ describe('JsonMemberWorkSyncStore', () => {
}); });
expect(claimed).toHaveLength(1); expect(claimed).toHaveLength(1);
expect( expect(
JSON.parse(await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8')) JSON.parse(
.items[input.id] await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8')
).items[input.id]
).toMatchObject({ status: 'claimed' }); ).toMatchObject({ status: 'claimed' });
expect(auditEvents.map((event) => `${event.event}:${event.reason}`)).toEqual( expect(auditEvents.map((event) => `${event.event}:${event.reason}`)).toEqual(
expect.arrayContaining([ expect.arrayContaining([

View file

@ -215,4 +215,39 @@ describe('MemberWorkSyncTaskImpactResolver', () => {
diagnostics: [], 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'],
});
});
}); });

View file

@ -19,6 +19,7 @@ function makeInput(overrides: Partial<NudgeInput> = {}): NudgeInput {
messageKind: 'member_work_sync_nudge', messageKind: 'member_work_sync_nudge',
source: 'member-work-sync', source: 'member-work-sync',
actionMode: 'do', actionMode: 'do',
workSyncIntent: 'agenda_sync',
text: 'Please reconcile your current work state.', text: 'Please reconcile your current work state.',
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
}, },
@ -73,6 +74,9 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
summary: 'Work sync check', summary: 'Work sync check',
source: 'system_notification', source: 'system_notification',
messageKind: 'member_work_sync_nudge', messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
workSyncIntentKey: undefined,
workSyncReviewRequestEventIds: undefined,
}); });
}); });

View file

@ -299,7 +299,13 @@ async function forceRetryableOutboxDue(input: {
expect(touched).toBeGreaterThan(0); expect(touched).toBeGreaterThan(0);
await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8'); await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
await fs.promises.rm( 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 } { force: true }
); );
} }
@ -794,7 +800,7 @@ describe('createMemberWorkSyncFeature composition', () => {
'utf8' 'utf8'
); );
expect(journal).toContain('"event":"nudge_skipped"'); 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"'); expect(journal).not.toContain('"event":"nudge_delivered"');
} finally { } finally {
await feature.dispose(); await feature.dispose();
@ -1098,7 +1104,7 @@ describe('createMemberWorkSyncFeature composition', () => {
'utf8' 'utf8'
); );
expect(journal).toContain('"event":"nudge_skipped"'); expect(journal).toContain('"event":"nudge_skipped"');
expect(journal).toContain('"reason":"phase2_not_ready"'); expect(journal).toContain('"reason":"blocking_metrics"');
}); });
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
@ -1301,7 +1307,14 @@ describe('createMemberWorkSyncFeature composition', () => {
).toHaveLength(2); ).toHaveLength(2);
const journal = await fs.promises.readFile( 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' 'utf8'
); );
const events = journal const events = journal
@ -2422,10 +2435,7 @@ describe('createMemberWorkSyncFeature composition', () => {
try { try {
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' }); const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' });
expect(env).toEqual({ expect(env).toEqual({
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
root,
'.member-work-sync/runtime-hooks'
),
}); });
await expect( await expect(
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
@ -2448,10 +2458,7 @@ describe('createMemberWorkSyncFeature composition', () => {
try { try {
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }); const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' });
expect(env).toEqual({ expect(env).toEqual({
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
root,
'.member-work-sync/runtime-hooks'
),
}); });
await expect( await expect(
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
@ -2470,10 +2477,7 @@ describe('createMemberWorkSyncFeature composition', () => {
}); });
expect(env).toEqual({ expect(env).toEqual({
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
root,
'.member-work-sync/runtime-hooks'
),
}); });
await expect( await expect(
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))

View file

@ -193,6 +193,80 @@ async function writeOpenCodeLedgerBundle(
); );
} }
async function writeWarningOnlyLedgerNotice(
projectDir: string,
overrides?: Partial<{
taskId: string;
memberName: string;
message: string;
}>
): Promise<void> {
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<void> {
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 { function persistedEntryPath(baseDir: string): string {
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`); return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
} }
@ -388,7 +462,9 @@ describe('ChangeExtractorService', () => {
const { service } = createService({ logPaths: [] }); const { service } = createService({ logPaths: [] });
const getTaskChanges = vi const getTaskChanges = vi
.spyOn(service, 'getTaskChanges') .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, [ const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
{ taskId: 'task-1', options: SUMMARY_OPTIONS }, { taskId: 'task-1', options: SUMMARY_OPTIONS },
@ -405,7 +481,9 @@ describe('ChangeExtractorService', () => {
const { service } = createService({ logPaths: [] }); const { service } = createService({ logPaths: [] });
const getTaskChanges = vi const getTaskChanges = vi
.spyOn(service, 'getTaskChanges') .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, [ const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
null, null,
@ -418,21 +496,48 @@ describe('ChangeExtractorService', () => {
expect(getTaskChanges).toHaveBeenCalledTimes(1); 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<typeof service.getTeamTaskChangeSummaries>[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 () => { it('does not reuse detailed task-change cache across different scope inputs', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
const aliceLogPath = path.join(tmpDir, 'alice.jsonl'); const aliceLogPath = path.join(tmpDir, 'alice.jsonl');
await writeJsonl(aliceLogPath, [ 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) => const findLogFileRefsForTask = vi.fn(
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : [] async (_teamName: string, _taskId: string, options?: any) =>
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
); );
const service = createService({ logPaths: [aliceLogPath], findLogFileRefsForTask }).service; 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, { const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice', owner: 'alice',
status: 'completed', status: 'completed',
@ -449,7 +554,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-summary.jsonl'); const logPath = path.join(tmpDir, 'alice-summary.jsonl');
await writeJsonl(logPath, [ 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] }); const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath] });
@ -477,7 +587,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-restart.jsonl'); const logPath = path.join(tmpDir, 'alice-restart.jsonl');
await writeJsonl(logPath, [ 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] }); const first = createService({ logPaths: [logPath] });
@ -500,15 +615,30 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-refresh.jsonl'); const logPath = path.join(tmpDir, 'alice-refresh.jsonl');
await writeJsonl(logPath, [ 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] }); const { service } = createService({ logPaths: [logPath] });
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await writeJsonl(logPath, [ await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'), buildAssistantWriteEntry(
buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'), '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, { const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
@ -532,7 +662,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-review.jsonl'); const logPath = path.join(tmpDir, 'alice-review.jsonl');
await writeJsonl(logPath, [ 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] }); const { service } = createService({ logPaths: [logPath] });
@ -565,7 +700,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-project-drift.jsonl'); const logPath = path.join(tmpDir, 'alice-project-drift.jsonl');
await writeJsonl(logPath, [ 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( await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges(
@ -574,11 +714,7 @@ describe('ChangeExtractorService', () => {
SUMMARY_OPTIONS SUMMARY_OPTIONS
); );
const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' }); const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' });
await drifted.service.getTaskChanges( await drifted.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
TEAM_NAME,
TASK_ID,
SUMMARY_OPTIONS
);
expect((drifted.findLogFileRefsForTask as any).mock.calls.length).toBeGreaterThan(1); 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'); const logPath = path.join(tmpDir, 'alice-missing-task.jsonl');
await writeJsonl(logPath, [ 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 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' }); await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
}); });
@ -607,10 +756,19 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-corrupt.jsonl'); const logPath = path.join(tmpDir, 'alice-corrupt.jsonl');
await writeJsonl(logPath, [ 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(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {});
await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8'); await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8');
@ -630,7 +788,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-fallback.jsonl'); const logPath = path.join(tmpDir, 'alice-fallback.jsonl');
await writeJsonl(logPath, [ 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( const service = new ChangeExtractorService(
@ -669,10 +832,20 @@ describe('ChangeExtractorService', () => {
const firstLogPath = path.join(tmpDir, 'first.jsonl'); const firstLogPath = path.join(tmpDir, 'first.jsonl');
const secondLogPath = path.join(tmpDir, 'second.jsonl'); const secondLogPath = path.join(tmpDir, 'second.jsonl');
await writeJsonl(firstLogPath, [ 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, [ 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({ const service = createService({
@ -722,7 +895,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl'); const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl');
await writeJsonl(logPath, [ 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(); const computeTaskChanges = vi.fn();
@ -752,7 +930,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl'); const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl');
await writeJsonl(logPath, [ 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 () => { const computeTaskChanges = vi.fn(async () => {
@ -783,7 +966,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl'); const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl');
await writeJsonl(logPath, [ 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()); const computeTaskChanges = vi.fn(async () => makeTaskChangeResult());
@ -808,7 +996,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl'); const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl');
await writeJsonl(logPath, [ 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 = { const firstWorker = {
@ -1041,7 +1234,9 @@ describe('ChangeExtractorService', () => {
})); }));
const workerClient = { const workerClient = {
isAvailable: vi.fn(() => true), 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({ const { service } = createService({
logPaths: [], logPaths: [],
@ -1057,6 +1252,196 @@ describe('ChangeExtractorService', () => {
expect(upsertEntry).not.toHaveBeenCalled(); 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 () => { it('backfills OpenCode ledger artifacts once before falling back to legacy extraction', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
@ -1697,7 +2082,9 @@ describe('ChangeExtractorService', () => {
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual( expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json') 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 () => { it('does not cache negative OpenCode backfill while delivery context already exists', async () => {
@ -1759,9 +2146,10 @@ describe('ChangeExtractorService', () => {
skippedEvents: 0, skippedEvents: 0,
outcome, outcome,
notices: [], notices: [],
diagnostics: outcome === 'transient-error' diagnostics:
? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.'] outcome === 'transient-error'
: [], ? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.']
: [],
}; };
}); });
const workerClient = { const workerClient = {
@ -1809,11 +2197,15 @@ describe('ChangeExtractorService', () => {
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual( expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json') 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(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
expect.stringContaining('delivery-context.json') 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 () => { 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) => ({ const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
schemaVersion: 1, schemaVersion: 1,
providerId: 'opencode', providerId: 'opencode',
opencodeTaskLedgerEvidenceContractVersion: opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
teamName: input.teamName, teamName: input.teamName,
taskId: input.taskId, taskId: input.taskId,
projectDir: input.projectDir, projectDir: input.projectDir,

View file

@ -58,6 +58,25 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => {
expect(decision.controlText).not.toContain('reportToken='); 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', () => { it('repairs visible replies that missed required taskRefs with exact metadata', () => {
const taskRef = { taskId: 'task-refs-1', displayId: 'refs-1', teamName: 'team-a' }; const taskRef = { taskId: 'task-refs-1', displayId: 'refs-1', teamName: 'team-a' };
const decision = decideOpenCodePromptDeliveryRepair( const decision = decideOpenCodePromptDeliveryRepair(

View file

@ -593,6 +593,47 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(sentText).not.toContain('You must not end this turn empty.'); 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<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
>(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 () => { it('does not parse legacy native SendMessage wording to infer OpenCode reply recipient', async () => {
const sendOpenCodeTeamMessage = vi.fn< const sendOpenCodeTeamMessage = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']> NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>

View file

@ -8042,10 +8042,10 @@ describe('TeamProvisioningService', () => {
})); }));
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
await expect( await expect(
svc.deliverOpenCodeMemberMessage('team-a', { svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob', memberName: 'bob',
text: 'Work sync check for #task-1.', text: 'Work sync check for #task-1.',
messageId: 'msg-work-sync-report', messageId: 'msg-work-sync-report',
replyRecipient: 'team-lead', replyRecipient: 'team-lead',
actionMode: 'do', 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<string, unknown>) => ({
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 () => { it('keeps member work sync status-only OpenCode deliveries pending', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({ const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({

View file

@ -431,7 +431,11 @@ describe('TaskDetailDialog changes summary loading', () => {
'task-attention', 'task-attention',
expect.objectContaining({ summaryOnly: true }) 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 () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -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<typeof vi.fn>;
sendMessageToRun: ReturnType<typeof vi.fn>;
teamChangeEvents: TeamChangeEvent[];
invalidations: { teamName: string; memberName: string }[];
}
interface TeamProvisioningSideEffectAccess {
aliveRunByTeam: Map<string, string>;
runs: Map<string, unknown>;
sendMessageToRun: (run: unknown, text: string) => Promise<void>;
handleOpenCodeRuntimeDeliveryUserFacingSideEffects: (
record: OpenCodePromptDeliveryLedgerRecord
) => Promise<void>;
openCodeRuntimeDeliveryAdvisoryReviewTimers: Map<string, ReturnType<typeof setTimeout>>;
}
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<MemberRuntimeAdvisory | null> {
const service = new TeamMemberRuntimeAdvisoryService({
findMemberLogs: vi.fn(() => Promise.resolve([])),
});
return await service.getMemberAdvisory(TEAM_NAME, MEMBER_NAME);
}
async function renderMemberCardText(
runtimeAdvisory: MemberRuntimeAdvisory | null
): Promise<string> {
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<SideEffectHarness> {
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> = {}
): 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<void> {
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<void> {
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'
);
}

View file

@ -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 () => { it('clears OpenCode runtime diagnostics only for the matching message id', async () => {
const store = createSliceStore(); const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({ hoisted.sendMessage.mockResolvedValue({

View file

@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildOpenCodeRuntimeDeliveryDiagnostics } from '../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import {
buildOpenCodeRuntimeDeliveryDiagnostics,
shouldClearPendingReplyForOpenCodeRuntimeDelivery,
} from '../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics';
describe('openCodeRuntimeDeliveryDiagnostics', () => { describe('openCodeRuntimeDeliveryDiagnostics', () => {
it('honors user-visible checking impact over raw terminal delivery facts', () => { it('honors user-visible checking impact over raw terminal delivery facts', () => {
@ -58,6 +61,22 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
expect(diagnostics).toEqual({ warning: null, debugDetails: null }); 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', () => { it('surfaces terminal empty assistant turn in the compact failed warning', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true, deliveredToInbox: true,