feat(sync): expand member work review signals
This commit is contained in:
parent
2cfb3d9dc9
commit
bda2af87e4
51 changed files with 4107 additions and 530 deletions
|
|
@ -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 } : {}),
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' &&
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 ?? [],
|
||||||
|
|
|
||||||
|
|
@ -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.`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>',
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
291
src/renderer/components/team/useTeamChangesSummaries.ts
Normal file
291
src/renderer/components/team/useTeamChangesSummaries.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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']>
|
||||||
|
|
|
||||||
|
|
@ -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>) => ({
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue