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' ||
|
||||
messageKind === 'slash_command' ||
|
||||
messageKind === 'slash_command_result' ||
|
||||
messageKind === 'task_comment_notification'
|
||||
messageKind === 'task_comment_notification' ||
|
||||
messageKind === 'member_work_sync_nudge'
|
||||
? messageKind
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeWorkSyncIntent(workSyncIntent) {
|
||||
return workSyncIntent === 'agenda_sync' || workSyncIntent === 'review_pickup'
|
||||
? workSyncIntent
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const items = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))];
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
function normalizeSlashCommand(slashCommand) {
|
||||
if (!slashCommand || typeof slashCommand !== 'object') {
|
||||
return undefined;
|
||||
|
|
@ -123,6 +138,8 @@ function buildMessage(flags, defaults) {
|
|||
const attachments = normalizeAttachments(flags.attachments);
|
||||
const taskRefs = normalizeTaskRefs(flags.taskRefs);
|
||||
const messageKind = normalizeMessageKind(flags.messageKind);
|
||||
const workSyncIntent = normalizeWorkSyncIntent(flags.workSyncIntent);
|
||||
const workSyncReviewRequestEventIds = normalizeStringList(flags.workSyncReviewRequestEventIds);
|
||||
const slashCommand = normalizeSlashCommand(flags.slashCommand);
|
||||
const commandOutput = normalizeCommandOutput(flags.commandOutput);
|
||||
|
||||
|
|
@ -173,6 +190,11 @@ function buildMessage(flags, defaults) {
|
|||
}
|
||||
: {}),
|
||||
...(messageKind ? { messageKind } : {}),
|
||||
...(workSyncIntent ? { workSyncIntent } : {}),
|
||||
...(typeof flags.workSyncIntentKey === 'string' && flags.workSyncIntentKey.trim()
|
||||
? { workSyncIntentKey: flags.workSyncIntentKey.trim() }
|
||||
: {}),
|
||||
...(workSyncReviewRequestEventIds ? { workSyncReviewRequestEventIds } : {}),
|
||||
...(slashCommand ? { slashCommand } : {}),
|
||||
...(commandOutput ? { commandOutput } : {}),
|
||||
...(attachments ? { attachments } : {}),
|
||||
|
|
|
|||
|
|
@ -1130,7 +1130,10 @@ describe('agent-teams-controller API', () => {
|
|||
commentId: 'comment-123',
|
||||
relayOfMessageId: 'm-original-1',
|
||||
source: 'system_notification',
|
||||
messageKind: 'task_comment_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'review_pickup',
|
||||
workSyncIntentKey: 'review-pickup:evt-1',
|
||||
workSyncReviewRequestEventIds: ['evt-1'],
|
||||
leadSessionId: 'session-42',
|
||||
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
|
||||
});
|
||||
|
|
@ -1142,7 +1145,10 @@ describe('agent-teams-controller API', () => {
|
|||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].source).toBe('system_notification');
|
||||
expect(rows[0].messageKind).toBe('task_comment_notification');
|
||||
expect(rows[0].messageKind).toBe('member_work_sync_nudge');
|
||||
expect(rows[0].workSyncIntent).toBe('review_pickup');
|
||||
expect(rows[0].workSyncIntentKey).toBe('review-pickup:evt-1');
|
||||
expect(rows[0].workSyncReviewRequestEventIds).toEqual(['evt-1']);
|
||||
expect(rows[0].commentId).toBe('comment-123');
|
||||
expect(rows[0].relayOfMessageId).toBe('m-original-1');
|
||||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { AgentAttachmentError } from '../core/domain';
|
||||
export * from './infrastructure/attachmentArtifactStore';
|
||||
export * from './providers/claudeAttachmentAdapter';
|
||||
export * from './providers/codexNativeAttachmentAdapter';
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ export type MemberWorkSyncActionableWorkPriority =
|
|||
|
||||
export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
||||
|
||||
export type MemberWorkSyncReviewObligation = 'review_pickup_required' | 'review_in_progress';
|
||||
|
||||
export type MemberWorkSyncNudgeIntent = 'agenda_sync' | 'review_pickup';
|
||||
|
||||
export type MemberWorkSyncReviewPickupDeliveryState =
|
||||
| 'inbox_persisted'
|
||||
| 'prompt_accepted'
|
||||
| 'response_proven';
|
||||
|
||||
export interface MemberWorkSyncActionableWorkItem {
|
||||
taskId: string;
|
||||
displayId?: string;
|
||||
|
|
@ -35,6 +44,15 @@ export interface MemberWorkSyncActionableWorkItem {
|
|||
owner?: string;
|
||||
reviewer?: string;
|
||||
reviewState?: string;
|
||||
reviewCycleId?: string;
|
||||
reviewRequestEventId?: string;
|
||||
reviewRequestedAt?: string;
|
||||
reviewStartedEventId?: string;
|
||||
reviewStartedAt?: string;
|
||||
reviewStartedBy?: string;
|
||||
reviewObligation?: MemberWorkSyncReviewObligation;
|
||||
canBypassPhase2?: boolean;
|
||||
reviewDiagnostics?: string[];
|
||||
needsClarification?: 'lead' | 'user';
|
||||
blockerTaskIds?: string[];
|
||||
blockedByTaskIds?: string[];
|
||||
|
|
@ -220,6 +238,9 @@ export interface MemberWorkSyncNudgePayload {
|
|||
messageKind: 'member_work_sync_nudge';
|
||||
source: 'member-work-sync';
|
||||
actionMode: 'do';
|
||||
workSyncIntent: MemberWorkSyncNudgeIntent;
|
||||
workSyncIntentKey?: string;
|
||||
workSyncReviewRequestEventIds?: string[];
|
||||
text: string;
|
||||
taskRefs: {
|
||||
taskId: string;
|
||||
|
|
@ -240,6 +261,8 @@ export interface MemberWorkSyncOutboxItem {
|
|||
claimedBy?: string;
|
||||
claimedAt?: string;
|
||||
deliveredMessageId?: string;
|
||||
deliveryState?: MemberWorkSyncReviewPickupDeliveryState;
|
||||
deliveryDiagnostics?: string[];
|
||||
lastError?: string;
|
||||
nextAttemptAt?: string;
|
||||
createdAt: string;
|
||||
|
|
@ -279,6 +302,8 @@ export interface MemberWorkSyncOutboxMarkDeliveredInput {
|
|||
id: string;
|
||||
attemptGeneration: number;
|
||||
deliveredMessageId: string;
|
||||
deliveryState?: MemberWorkSyncReviewPickupDeliveryState;
|
||||
deliveryDiagnostics?: string[];
|
||||
nowIso: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../cont
|
|||
export type MemberWorkSyncNudgeActivationReason =
|
||||
| 'shadow_ready'
|
||||
| 'opencode_targeted_shadow_collecting'
|
||||
| 'review_pickup_required'
|
||||
| 'status_not_nudgeable'
|
||||
| 'blocking_metrics'
|
||||
| 'phase2_not_ready';
|
||||
|
|
@ -27,10 +28,35 @@ function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean {
|
|||
status.providerId === 'opencode' &&
|
||||
status.state === 'needs_sync' &&
|
||||
status.agenda.items.length > 0 &&
|
||||
!isReviewPickupAgenda(status) &&
|
||||
status.shadow?.wouldNudge === true
|
||||
);
|
||||
}
|
||||
|
||||
function isStrictReviewPickupItem(item: MemberWorkSyncStatus['agenda']['items'][number]): boolean {
|
||||
return (
|
||||
item.kind === 'review' &&
|
||||
item.evidence.reviewObligation === 'review_pickup_required' &&
|
||||
item.evidence.canBypassPhase2 === true &&
|
||||
typeof item.evidence.reviewRequestEventId === 'string' &&
|
||||
item.evidence.reviewRequestEventId.length > 0 &&
|
||||
(item.evidence.reviewDiagnostics?.length ?? 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function isReviewPickupAgenda(status: MemberWorkSyncStatus): boolean {
|
||||
return status.agenda.items.length > 0 && status.agenda.items.every(isStrictReviewPickupItem);
|
||||
}
|
||||
|
||||
function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean {
|
||||
return (
|
||||
status.state === 'needs_sync' &&
|
||||
status.shadow?.wouldNudge === true &&
|
||||
status.agenda.items.length > 0 &&
|
||||
status.agenda.items.every(isStrictReviewPickupItem)
|
||||
);
|
||||
}
|
||||
|
||||
export function decideMemberWorkSyncNudgeActivation(input: {
|
||||
status: MemberWorkSyncStatus;
|
||||
metrics: MemberWorkSyncTeamMetrics;
|
||||
|
|
@ -43,6 +69,10 @@ export function decideMemberWorkSyncNudgeActivation(input: {
|
|||
return { active: false, reason: 'blocking_metrics' };
|
||||
}
|
||||
|
||||
if (isReviewPickupRequiredCandidate(input.status)) {
|
||||
return { active: true, reason: 'review_pickup_required' };
|
||||
}
|
||||
|
||||
if (input.metrics.phase2Readiness.state === 'shadow_ready') {
|
||||
return { active: true, reason: 'shadow_ready' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncA
|
|||
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||
|
||||
import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts';
|
||||
import type {
|
||||
MemberWorkSyncAgenda,
|
||||
MemberWorkSyncOutboxItem,
|
||||
MemberWorkSyncStatus,
|
||||
} from '../../contracts';
|
||||
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||
|
|
@ -53,6 +57,42 @@ function nextRetryAt(item: MemberWorkSyncOutboxItem, nowIso: string): string {
|
|||
return addMinutes(nowIso, cappedMinutes + stableJitterMinutes(item.id, item.attemptGeneration));
|
||||
}
|
||||
|
||||
function isReviewPickupOutboxItem(item: MemberWorkSyncOutboxItem): boolean {
|
||||
return item.payload.workSyncIntent === 'review_pickup';
|
||||
}
|
||||
|
||||
function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] {
|
||||
return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])]
|
||||
.filter((id) => id.length > 0)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function getAgendaReviewPickupRequestEventIds(agenda: MemberWorkSyncAgenda): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
agenda.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.kind === 'review' &&
|
||||
item.evidence.reviewObligation === 'review_pickup_required' &&
|
||||
item.evidence.canBypassPhase2 === true &&
|
||||
(item.evidence.reviewDiagnostics?.length ?? 0) === 0
|
||||
)
|
||||
.map((item) => item.evidence.reviewRequestEventId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function reviewPickupRequestIdsStillMatch(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
agenda: MemberWorkSyncAgenda
|
||||
): boolean {
|
||||
const payloadIds = getPayloadReviewRequestEventIds(item);
|
||||
const agendaIds = getAgendaReviewPickupRequestEventIds(agenda);
|
||||
return payloadIds.length > 0 && payloadIds.every((id) => agendaIds.includes(id));
|
||||
}
|
||||
|
||||
export class MemberWorkSyncNudgeDispatcher {
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||
|
||||
|
|
@ -114,6 +154,10 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
);
|
||||
return 'retryable';
|
||||
}
|
||||
if (revalidation.reason.startsWith('review_pickup_delivery_unavailable:')) {
|
||||
await this.markReviewPickupDeliveryUnavailable(item, nowIso, revalidation.reason);
|
||||
return 'superseded';
|
||||
}
|
||||
await outbox.markSuperseded({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
|
|
@ -145,6 +189,15 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
await this.appendDispatchAudit(item, 'nudge_skipped', 'inbox_payload_conflict');
|
||||
return 'terminal';
|
||||
}
|
||||
if (isReviewPickupOutboxItem(item)) {
|
||||
return await this.deliverReviewPickupNudge(
|
||||
item,
|
||||
inserted.messageId,
|
||||
inserted.inserted,
|
||||
revalidation.providerId,
|
||||
nowIso
|
||||
);
|
||||
}
|
||||
await outbox.markDelivered({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
|
|
@ -175,6 +228,126 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
}
|
||||
}
|
||||
|
||||
private async deliverReviewPickupNudge(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
messageId: string,
|
||||
inserted: boolean,
|
||||
providerId: MemberWorkSyncStatus['providerId'] | undefined,
|
||||
nowIso: string
|
||||
): Promise<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(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
event: MemberWorkSyncAuditEventName,
|
||||
|
|
@ -248,11 +421,10 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
diagnostics: [...agenda.diagnostics, ...decision.diagnostics],
|
||||
...(providerId ? { providerId } : {}),
|
||||
};
|
||||
if (
|
||||
decision.state !== 'needs_sync' ||
|
||||
agenda.items.length === 0 ||
|
||||
agenda.fingerprint !== item.agendaFingerprint
|
||||
) {
|
||||
const agendaStillMatches =
|
||||
agenda.fingerprint === item.agendaFingerprint ||
|
||||
(isReviewPickupOutboxItem(item) && reviewPickupRequestIdsStillMatch(item, agenda));
|
||||
if (decision.state !== 'needs_sync' || agenda.items.length === 0 || !agendaStillMatches) {
|
||||
return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false };
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +437,30 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
metrics,
|
||||
});
|
||||
if (!activation.active) {
|
||||
return { ok: false, reason: 'phase2_not_ready', retryable: true };
|
||||
const reason =
|
||||
activation.reason === 'blocking_metrics'
|
||||
? 'blocking_metrics'
|
||||
: activation.reason === 'status_not_nudgeable'
|
||||
? 'status_not_nudgeable'
|
||||
: 'phase2_not_ready';
|
||||
return { ok: false, reason, retryable: true };
|
||||
}
|
||||
|
||||
if (isReviewPickupOutboxItem(item)) {
|
||||
const capability = await this.deps.reviewPickupDelivery?.canDeliver({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
providerId,
|
||||
});
|
||||
if (!capability?.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `review_pickup_delivery_unavailable:${
|
||||
capability?.reason ?? 'delivery_port_unavailable'
|
||||
}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const recentDelivered = await this.deps.outboxStore?.countRecentDelivered({
|
||||
|
|
|
|||
|
|
@ -6,13 +6,44 @@ import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActiva
|
|||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
function getReviewRequestEventIds(status: MemberWorkSyncStatus): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
status.agenda.items
|
||||
.map((item) => item.evidence.reviewRequestEventId?.trim())
|
||||
.filter((id): id is string => Boolean(id))
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function filterReviewPickupStatusByRequestIds(
|
||||
status: MemberWorkSyncStatus,
|
||||
reviewRequestEventIds: string[]
|
||||
): MemberWorkSyncStatus {
|
||||
const allowed = new Set(reviewRequestEventIds);
|
||||
return {
|
||||
...status,
|
||||
agenda: {
|
||||
...status.agenda,
|
||||
items: status.agenda.items.filter((item) => {
|
||||
const eventId = item.evidence.reviewRequestEventId?.trim();
|
||||
return eventId ? allowed.has(eventId) : false;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncNudgeOutboxPlanResult {
|
||||
planned: boolean;
|
||||
code:
|
||||
| 'outbox_unavailable'
|
||||
| 'metrics_unavailable'
|
||||
| 'status_not_nudgeable'
|
||||
| 'blocking_metrics'
|
||||
| 'phase2_not_ready'
|
||||
| 'review_pickup_delivery_unavailable'
|
||||
| 'review_pickup_already_delivered_still_stuck'
|
||||
| 'review_pickup_delivery_failed_still_stuck'
|
||||
| 'created'
|
||||
| 'existing'
|
||||
| 'payload_conflict';
|
||||
|
|
@ -29,7 +60,7 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
|||
return { planned: false, code: 'metrics_unavailable' };
|
||||
}
|
||||
|
||||
const input = buildMemberWorkSyncOutboxEnsureInput({
|
||||
let input = buildMemberWorkSyncOutboxEnsureInput({
|
||||
status,
|
||||
hash: this.deps.hash,
|
||||
nowIso: status.evaluatedAt,
|
||||
|
|
@ -41,12 +72,76 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
|||
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
|
||||
const activation = decideMemberWorkSyncNudgeActivation({ status, metrics });
|
||||
if (!activation.active) {
|
||||
await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' });
|
||||
return { planned: false, code: 'phase2_not_ready' };
|
||||
const code =
|
||||
activation.reason === 'blocking_metrics'
|
||||
? 'blocking_metrics'
|
||||
: activation.reason === 'status_not_nudgeable'
|
||||
? 'status_not_nudgeable'
|
||||
: 'phase2_not_ready';
|
||||
await this.appendPlanAudit(status, { planned: false, code });
|
||||
return { planned: false, code };
|
||||
}
|
||||
|
||||
if (input.payload.workSyncIntent === 'review_pickup') {
|
||||
const capability = await this.deps.reviewPickupDelivery?.canDeliver({
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
providerId: status.providerId,
|
||||
});
|
||||
if (!capability?.ok) {
|
||||
const diagnostics = [
|
||||
capability?.reason ?? 'review_pickup_delivery_port_unavailable',
|
||||
...(capability?.diagnostics ?? []),
|
||||
];
|
||||
await this.appendReviewPickupDeliveryUnavailableAudit(status, diagnostics);
|
||||
const result = {
|
||||
planned: false,
|
||||
code: 'review_pickup_delivery_unavailable',
|
||||
} as const;
|
||||
await this.appendPlanAudit(status, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const requestedEventIds = input.payload.workSyncReviewRequestEventIds ?? [];
|
||||
const deliveredEventIds =
|
||||
(await this.deps.outboxStore.findDeliveredReviewPickupRequestEventIds?.({
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
reviewRequestEventIds: requestedEventIds,
|
||||
})) ?? [];
|
||||
if (deliveredEventIds.length > 0) {
|
||||
const delivered = new Set(deliveredEventIds);
|
||||
const undeliveredEventIds = requestedEventIds.filter((eventId) => !delivered.has(eventId));
|
||||
if (undeliveredEventIds.length === 0) {
|
||||
const code = 'review_pickup_already_delivered_still_stuck' as const;
|
||||
await this.appendReviewPickupEscalationAudit(status, code);
|
||||
await this.appendPlanAudit(status, { planned: false, code });
|
||||
return { planned: false, code };
|
||||
}
|
||||
|
||||
const filteredStatus = filterReviewPickupStatusByRequestIds(status, undeliveredEventIds);
|
||||
const filteredInput = buildMemberWorkSyncOutboxEnsureInput({
|
||||
status: filteredStatus,
|
||||
hash: this.deps.hash,
|
||||
nowIso: status.evaluatedAt,
|
||||
});
|
||||
if (!filteredInput) {
|
||||
const code = 'status_not_nudgeable' as const;
|
||||
await this.appendPlanAudit(status, { planned: false, code });
|
||||
return { planned: false, code };
|
||||
}
|
||||
input = filteredInput;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.deps.outboxStore.ensurePending(input);
|
||||
if (!result.ok) {
|
||||
if (input.payload.workSyncIntent === 'review_pickup' && result.item.status === 'delivered') {
|
||||
const code = 'review_pickup_already_delivered_still_stuck' as const;
|
||||
await this.appendReviewPickupEscalationAudit(status, code);
|
||||
await this.appendPlanAudit(status, { planned: false, code });
|
||||
return { planned: false, code };
|
||||
}
|
||||
this.deps.logger?.warn('member work sync nudge outbox payload conflict', {
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
|
|
@ -58,11 +153,108 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
|||
return { planned: false, code: 'payload_conflict' };
|
||||
}
|
||||
|
||||
if (input.payload.workSyncIntent === 'review_pickup' && result.item.status === 'delivered') {
|
||||
const code = 'review_pickup_already_delivered_still_stuck' as const;
|
||||
await this.appendReviewPickupEscalationAudit(status, code);
|
||||
await this.appendPlanAudit(status, { planned: false, code });
|
||||
return { planned: false, code };
|
||||
}
|
||||
if (
|
||||
input.payload.workSyncIntent === 'review_pickup' &&
|
||||
result.item.status === 'failed_terminal'
|
||||
) {
|
||||
const code = 'review_pickup_delivery_failed_still_stuck' as const;
|
||||
await this.appendReviewPickupEscalationAudit(status, code);
|
||||
await this.appendPlanAudit(status, { planned: false, code });
|
||||
return { planned: false, code };
|
||||
}
|
||||
|
||||
const planResult = { planned: true, code: result.outcome } as const;
|
||||
await this.appendPlanAudit(status, planResult);
|
||||
return planResult;
|
||||
}
|
||||
|
||||
private async appendReviewPickupEscalationAudit(
|
||||
status: MemberWorkSyncStatus,
|
||||
reason: string
|
||||
): Promise<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(
|
||||
status: MemberWorkSyncStatus,
|
||||
result: MemberWorkSyncNudgeOutboxPlanResult
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ export type MemberWorkSyncAuditEventName =
|
|||
| 'nudge_skipped'
|
||||
| 'nudge_retryable'
|
||||
| 'nudge_superseded'
|
||||
| 'review_pickup_delivery_unavailable'
|
||||
| 'review_pickup_member_nudge_delivered'
|
||||
| 'review_pickup_escalated'
|
||||
| 'review_pickup_wake_failed_retryable'
|
||||
| 'watchdog_cooldown_active'
|
||||
| 'member_busy'
|
||||
| 'team_inactive'
|
||||
|
|
@ -152,6 +156,11 @@ export interface MemberWorkSyncOutboxStorePort {
|
|||
markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void>;
|
||||
markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void>;
|
||||
countRecentDelivered(input: MemberWorkSyncOutboxCountRecentDeliveredInput): Promise<number>;
|
||||
findDeliveredReviewPickupRequestEventIds?(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
reviewRequestEventIds: string[];
|
||||
}): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncInboxNudgePort {
|
||||
|
|
@ -193,6 +202,56 @@ export interface MemberWorkSyncNudgeDeliveryWakePort {
|
|||
}): 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 {
|
||||
clock: MemberWorkSyncClockPort;
|
||||
hash: MemberWorkSyncHashPort;
|
||||
|
|
@ -204,6 +263,8 @@ export interface MemberWorkSyncUseCaseDeps {
|
|||
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
|
||||
busySignal?: MemberWorkSyncBusySignalPort;
|
||||
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
|
||||
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
|
||||
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
|
||||
reportToken?: MemberWorkSyncReportTokenPort;
|
||||
auditJournal?: MemberWorkSyncAuditJournalPort;
|
||||
lifecycle?: MemberWorkSyncLifecyclePort;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
canonicalizeAgendaFingerprintPayload,
|
||||
formatAgendaFingerprint,
|
||||
} from './AgendaFingerprint';
|
||||
import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle';
|
||||
import { resolveCurrentReviewCycle, type ReviewHistoryEventLike } from './currentReviewCycle';
|
||||
import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName';
|
||||
|
||||
import type {
|
||||
|
|
@ -180,15 +180,44 @@ export function buildActionableWorkAgenda(
|
|||
continue;
|
||||
}
|
||||
|
||||
const reviewOwner = isReviewWorkflow
|
||||
? resolveCurrentReviewOwner({
|
||||
const reviewCycle = isReviewWorkflow
|
||||
? resolveCurrentReviewCycle({
|
||||
reviewState: workflowColumn,
|
||||
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
|
||||
historyEvents: task.historyEvents,
|
||||
})
|
||||
: null;
|
||||
const isSelfReview =
|
||||
Boolean(owner) &&
|
||||
Boolean(reviewCycle?.reviewer) &&
|
||||
sameMemberName(owner, reviewCycle?.reviewer);
|
||||
|
||||
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
|
||||
if (isSelfReview && activeLeadName && sameMemberName(activeLeadName, memberName)) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'clarification',
|
||||
priority: 'needs_clarification',
|
||||
reason: 'self_review_invalid',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
owner,
|
||||
reviewer: reviewCycle?.reviewer,
|
||||
reviewState: workflowColumn,
|
||||
...(reviewCycle?.reviewRequestEventId
|
||||
? { reviewRequestEventId: reviewCycle.reviewRequestEventId }
|
||||
: {}),
|
||||
...(reviewCycle?.historyEventIds.length
|
||||
? { historyEventIds: reviewCycle.historyEventIds }
|
||||
: {}),
|
||||
reviewDiagnostics: [
|
||||
...new Set([...(reviewCycle?.diagnostics ?? []), 'self_review_invalid']),
|
||||
].sort(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reviewCycle && !isSelfReview && sameMemberName(reviewCycle.reviewer, memberName)) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'review',
|
||||
|
|
@ -198,9 +227,30 @@ export function buildActionableWorkAgenda(
|
|||
status: task.status,
|
||||
...(owner ? { owner } : {}),
|
||||
reviewer: memberName,
|
||||
...(task.reviewState ? { reviewState: task.reviewState } : {}),
|
||||
...(reviewOwner.historyEventIds.length > 0
|
||||
? { historyEventIds: reviewOwner.historyEventIds }
|
||||
reviewState: workflowColumn,
|
||||
reviewCycleId: reviewCycle.reviewCycleId,
|
||||
reviewObligation: reviewCycle.obligation,
|
||||
canBypassPhase2: reviewCycle.canBypassPhase2,
|
||||
...(reviewCycle.reviewRequestEventId
|
||||
? { reviewRequestEventId: reviewCycle.reviewRequestEventId }
|
||||
: {}),
|
||||
...(reviewCycle.reviewRequestedAt
|
||||
? { reviewRequestedAt: reviewCycle.reviewRequestedAt }
|
||||
: {}),
|
||||
...(reviewCycle.reviewStartedEventId
|
||||
? { reviewStartedEventId: reviewCycle.reviewStartedEventId }
|
||||
: {}),
|
||||
...(reviewCycle.reviewStartedAt
|
||||
? { reviewStartedAt: reviewCycle.reviewStartedAt }
|
||||
: {}),
|
||||
...(reviewCycle.reviewStartedBy
|
||||
? { reviewStartedBy: reviewCycle.reviewStartedBy }
|
||||
: {}),
|
||||
...(reviewCycle.historyEventIds.length > 0
|
||||
? { historyEventIds: reviewCycle.historyEventIds }
|
||||
: {}),
|
||||
...(reviewCycle.diagnostics.length > 0
|
||||
? { reviewDiagnostics: [...reviewCycle.diagnostics].sort() }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ export function buildAgendaFingerprintPayload(input: {
|
|||
...(item.evidence.historyEventIds
|
||||
? { historyEventIds: [...item.evidence.historyEventIds].sort() }
|
||||
: {}),
|
||||
...(item.evidence.reviewDiagnostics
|
||||
? { reviewDiagnostics: [...item.evidence.reviewDiagnostics].sort() }
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,18 +29,100 @@ export function buildMemberWorkSyncNudgeId(input: {
|
|||
teamName: string;
|
||||
memberName: string;
|
||||
agendaFingerprint: string;
|
||||
intentKey?: string;
|
||||
}): string {
|
||||
return [
|
||||
MEMBER_WORK_SYNC_NUDGE_ID_PREFIX,
|
||||
input.teamName,
|
||||
input.memberName.trim().toLowerCase(),
|
||||
input.agendaFingerprint,
|
||||
input.intentKey ?? input.agendaFingerprint,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function getReviewPickupRequestEventIds(status: MemberWorkSyncStatus): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
status.agenda.items
|
||||
.map((item) => item.evidence.reviewRequestEventId?.trim())
|
||||
.filter((id): id is string => Boolean(id))
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function isReviewPickupNudgeStatus(status: MemberWorkSyncStatus): boolean {
|
||||
return (
|
||||
status.agenda.items.length > 0 &&
|
||||
status.agenda.items.every(
|
||||
(item) =>
|
||||
item.kind === 'review' &&
|
||||
item.evidence.reviewObligation === 'review_pickup_required' &&
|
||||
item.evidence.canBypassPhase2 === true &&
|
||||
typeof item.evidence.reviewRequestEventId === 'string' &&
|
||||
item.evidence.reviewRequestEventId.length > 0 &&
|
||||
(item.evidence.reviewDiagnostics?.length ?? 0) === 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncReviewPickupIntentKey(
|
||||
status: MemberWorkSyncStatus
|
||||
): string | null {
|
||||
const reviewRequestEventIds = getReviewPickupRequestEventIds(status);
|
||||
return reviewRequestEventIds.length > 0
|
||||
? `review-pickup:${reviewRequestEventIds.join('+')}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildTaskRefs(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload['taskRefs'] {
|
||||
return status.agenda.items.map((item) => ({
|
||||
teamName: status.teamName,
|
||||
taskId: item.taskId,
|
||||
displayId: item.displayId ?? item.taskId.slice(0, 8),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildAgendaPreview(status: MemberWorkSyncStatus): string {
|
||||
return status.agenda.items
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload {
|
||||
const taskRefs = buildTaskRefs(status);
|
||||
const preview = buildAgendaPreview(status);
|
||||
const reviewRequestEventIds = getReviewPickupRequestEventIds(status);
|
||||
const intentKey = buildMemberWorkSyncReviewPickupIntentKey(status);
|
||||
|
||||
return {
|
||||
from: 'system',
|
||||
to: status.memberName,
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
source: 'member-work-sync',
|
||||
actionMode: 'do',
|
||||
workSyncIntent: 'review_pickup',
|
||||
...(intentKey ? { workSyncIntentKey: intentKey } : {}),
|
||||
workSyncReviewRequestEventIds: reviewRequestEventIds,
|
||||
taskRefs,
|
||||
text: [
|
||||
'Review pickup required: a current review request is waiting for you.',
|
||||
preview ? `Review agenda: ${preview}.` : '',
|
||||
'Open the task, verify the current reviewState/status, then start or continue the review only if it is still assigned to you.',
|
||||
`If you cannot pick it up now, call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then report "blocked" or "still_working" only for the real current state.`,
|
||||
'Do not mark the review complete from this prompt alone, and do not reply only with acknowledgement.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncNudgePayload(
|
||||
status: MemberWorkSyncStatus
|
||||
): MemberWorkSyncNudgePayload {
|
||||
if (isReviewPickupNudgeStatus(status)) {
|
||||
return buildReviewPickupNudgePayload(status);
|
||||
}
|
||||
|
||||
const taskRefs = status.agenda.items.map((item) => ({
|
||||
teamName: status.teamName,
|
||||
taskId: item.taskId,
|
||||
|
|
@ -58,6 +140,7 @@ export function buildMemberWorkSyncNudgePayload(
|
|||
messageKind: 'member_work_sync_nudge',
|
||||
source: 'member-work-sync',
|
||||
actionMode: 'do',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
taskRefs,
|
||||
text: [
|
||||
'Work sync check: you have current actionable work assigned.',
|
||||
|
|
@ -98,11 +181,14 @@ export function buildMemberWorkSyncOutboxEnsureInput(input: {
|
|||
}
|
||||
|
||||
const payload = buildMemberWorkSyncNudgePayload(status);
|
||||
const intentKey =
|
||||
payload.workSyncIntent === 'review_pickup' ? payload.workSyncIntentKey : undefined;
|
||||
return {
|
||||
id: buildMemberWorkSyncNudgeId({
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
agendaFingerprint: status.agenda.fingerprint,
|
||||
intentKey,
|
||||
}),
|
||||
teamName: status.teamName,
|
||||
memberName: status.memberName,
|
||||
|
|
|
|||
|
|
@ -18,20 +18,45 @@ export type MemberWorkSyncReportTokenValidation =
|
|||
| { ok: false; reason: 'missing' | 'expired' | 'invalid' };
|
||||
|
||||
const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_REVIEW_PICKUP_STILL_WORKING_LEASE_MS = 3 * 60 * 1000;
|
||||
const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000;
|
||||
const MIN_LEASE_MS = 60_000;
|
||||
const MAX_LEASE_MS = 60 * 60 * 1000;
|
||||
const MAX_REVIEW_PICKUP_STILL_WORKING_LEASE_MS = 10 * 60 * 1000;
|
||||
|
||||
function agendaIsReviewPickupRequired(agenda: MemberWorkSyncAgenda): boolean {
|
||||
return (
|
||||
agenda.items.length > 0 &&
|
||||
agenda.items.every(
|
||||
(item) =>
|
||||
item.kind === 'review' &&
|
||||
item.evidence.reviewObligation === 'review_pickup_required' &&
|
||||
item.evidence.canBypassPhase2 === true
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function clampLeaseTtlMs(
|
||||
value: number | undefined,
|
||||
state: MemberWorkSyncReportState
|
||||
state: MemberWorkSyncReportState,
|
||||
agenda: MemberWorkSyncAgenda
|
||||
): number | undefined {
|
||||
if (state === 'caught_up') {
|
||||
return undefined;
|
||||
}
|
||||
const fallback = state === 'blocked' ? DEFAULT_BLOCKED_LEASE_MS : DEFAULT_STILL_WORKING_LEASE_MS;
|
||||
const isReviewPickupStillWorking =
|
||||
state === 'still_working' && agendaIsReviewPickupRequired(agenda);
|
||||
const fallback =
|
||||
state === 'blocked'
|
||||
? DEFAULT_BLOCKED_LEASE_MS
|
||||
: isReviewPickupStillWorking
|
||||
? DEFAULT_REVIEW_PICKUP_STILL_WORKING_LEASE_MS
|
||||
: DEFAULT_STILL_WORKING_LEASE_MS;
|
||||
const maxLease = isReviewPickupStillWorking
|
||||
? MAX_REVIEW_PICKUP_STILL_WORKING_LEASE_MS
|
||||
: MAX_LEASE_MS;
|
||||
const numeric = Number.isFinite(value) ? Math.floor(Number(value)) : fallback;
|
||||
return Math.min(MAX_LEASE_MS, Math.max(MIN_LEASE_MS, numeric));
|
||||
return Math.min(maxLease, Math.max(MIN_LEASE_MS, numeric));
|
||||
}
|
||||
|
||||
function agendaHasBlockedEvidence(
|
||||
|
|
@ -139,7 +164,7 @@ export function validateMemberWorkSyncReport(input: {
|
|||
};
|
||||
}
|
||||
|
||||
const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state);
|
||||
const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state, input.agenda);
|
||||
return {
|
||||
ok: true,
|
||||
code: 'accepted',
|
||||
|
|
|
|||
|
|
@ -10,21 +10,214 @@ export interface ReviewHistoryEventLike {
|
|||
to?: string;
|
||||
}
|
||||
|
||||
export type CurrentReviewObligation = 'review_pickup_required' | 'review_in_progress';
|
||||
|
||||
export interface CurrentReviewCycle {
|
||||
reviewer: string;
|
||||
obligation: CurrentReviewObligation;
|
||||
reviewCycleId: string;
|
||||
historyEventIds: string[];
|
||||
reviewRequestEventId?: string;
|
||||
reviewRequestedAt?: string;
|
||||
reviewStartedEventId?: string;
|
||||
reviewStartedAt?: string;
|
||||
reviewStartedBy?: string;
|
||||
canBypassPhase2: boolean;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface CurrentReviewOwner {
|
||||
reviewer: string;
|
||||
historyEventIds: string[];
|
||||
}
|
||||
|
||||
function compareEventsByTimestamp(
|
||||
left: ReviewHistoryEventLike,
|
||||
right: ReviewHistoryEventLike
|
||||
): number {
|
||||
const leftTime = Date.parse(left.timestamp ?? '');
|
||||
const rightTime = Date.parse(right.timestamp ?? '');
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
interface IndexedReviewEvent {
|
||||
event: ReviewHistoryEventLike;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface OpenReviewCycleEvidence {
|
||||
request?: IndexedReviewEvent;
|
||||
started?: IndexedReviewEvent;
|
||||
}
|
||||
|
||||
const REVIEW_EVENT_TYPES = new Set([
|
||||
'review_requested',
|
||||
'review_started',
|
||||
'review_approved',
|
||||
'review_changes_requested',
|
||||
'task_created',
|
||||
'status_changed',
|
||||
]);
|
||||
|
||||
function eventTimestampMs(event: ReviewHistoryEventLike): number | null {
|
||||
const parsed = Date.parse(event.timestamp ?? '');
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function compareIndexedEvents(left: IndexedReviewEvent, right: IndexedReviewEvent): number {
|
||||
const leftTime = eventTimestampMs(left.event);
|
||||
const rightTime = eventTimestampMs(right.event);
|
||||
if (leftTime !== null && rightTime !== null && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return 0;
|
||||
return left.index - right.index;
|
||||
}
|
||||
|
||||
function historyEventId(event?: IndexedReviewEvent): string | undefined {
|
||||
const id = event?.event.id?.trim();
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
function historyEventTimestamp(event?: IndexedReviewEvent): string | undefined {
|
||||
const timestamp = event?.event.timestamp?.trim();
|
||||
return timestamp || undefined;
|
||||
}
|
||||
|
||||
function uniqueIds(ids: (string | undefined)[]): string[] {
|
||||
return [...new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0))];
|
||||
}
|
||||
|
||||
function isReviewCycleBoundary(event: ReviewHistoryEventLike): boolean {
|
||||
if (event.type === 'task_created') {
|
||||
return true;
|
||||
}
|
||||
if (event.type === 'status_changed') {
|
||||
return event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectOpenReviewCycle(
|
||||
historyEvents: ReviewHistoryEventLike[]
|
||||
): OpenReviewCycleEvidence | null {
|
||||
let openCycle: OpenReviewCycleEvidence | null = null;
|
||||
const sortedEvents = historyEvents
|
||||
.map((event, index) => ({ event, index }))
|
||||
.filter(({ event }) => REVIEW_EVENT_TYPES.has(event.type))
|
||||
.sort(compareIndexedEvents);
|
||||
|
||||
for (const item of sortedEvents) {
|
||||
const { event } = item;
|
||||
if (isReviewCycleBoundary(event)) {
|
||||
openCycle = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'review_requested') {
|
||||
openCycle = { request: item };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'review_started') {
|
||||
openCycle = openCycle ? { ...openCycle, started: item } : { started: item };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
||||
openCycle = null;
|
||||
}
|
||||
}
|
||||
|
||||
return openCycle;
|
||||
}
|
||||
|
||||
export function resolveCurrentReviewCycle(input: {
|
||||
reviewState?: string | null;
|
||||
kanbanReviewer?: string | null;
|
||||
historyEvents?: ReviewHistoryEventLike[];
|
||||
}): CurrentReviewCycle | null {
|
||||
if (input.reviewState !== 'review') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kanbanReviewer = normalizeMemberName(input.kanbanReviewer);
|
||||
const openCycle = collectOpenReviewCycle(input.historyEvents ?? []);
|
||||
const diagnostics: string[] = [];
|
||||
|
||||
if (!openCycle) {
|
||||
if (!kanbanReviewer) {
|
||||
return null;
|
||||
}
|
||||
diagnostics.push('legacy_kanban_reviewer_without_current_review_cycle');
|
||||
return {
|
||||
reviewer: kanbanReviewer,
|
||||
obligation: 'review_in_progress',
|
||||
reviewCycleId: `kanban:${kanbanReviewer}`,
|
||||
historyEventIds: [],
|
||||
canBypassPhase2: false,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
const requestReviewer = normalizeMemberName(openCycle.request?.event.reviewer);
|
||||
const startedBy = normalizeMemberName(openCycle.started?.event.actor);
|
||||
const reviewer = requestReviewer || kanbanReviewer || startedBy;
|
||||
|
||||
if (!reviewer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestEventId = historyEventId(openCycle.request);
|
||||
const startedEventId = historyEventId(openCycle.started);
|
||||
const obligation: CurrentReviewObligation = openCycle.started
|
||||
? 'review_in_progress'
|
||||
: 'review_pickup_required';
|
||||
|
||||
if (openCycle.request && !requestEventId) {
|
||||
diagnostics.push('review_request_event_id_missing');
|
||||
}
|
||||
if (openCycle.request && !requestReviewer) {
|
||||
diagnostics.push('review_request_reviewer_missing');
|
||||
}
|
||||
if (!openCycle.request && openCycle.started) {
|
||||
diagnostics.push('review_started_without_review_request');
|
||||
}
|
||||
if (openCycle.started && !startedBy) {
|
||||
diagnostics.push('review_started_actor_missing');
|
||||
}
|
||||
if (
|
||||
openCycle.request &&
|
||||
openCycle.started &&
|
||||
requestReviewer &&
|
||||
startedBy &&
|
||||
requestReviewer !== startedBy
|
||||
) {
|
||||
diagnostics.push('review_started_actor_differs_from_requested_reviewer');
|
||||
}
|
||||
if (
|
||||
openCycle.request &&
|
||||
requestReviewer &&
|
||||
kanbanReviewer &&
|
||||
requestReviewer !== kanbanReviewer
|
||||
) {
|
||||
diagnostics.push('kanban_reviewer_differs_from_review_request');
|
||||
}
|
||||
if (openCycle.started && startedBy && kanbanReviewer && startedBy !== kanbanReviewer) {
|
||||
diagnostics.push('kanban_reviewer_differs_from_review_started_actor');
|
||||
}
|
||||
|
||||
const reviewCycleId = requestEventId ?? startedEventId ?? `kanban:${reviewer}`;
|
||||
const canBypassPhase2 =
|
||||
obligation === 'review_pickup_required' && Boolean(requestEventId) && diagnostics.length === 0;
|
||||
|
||||
return {
|
||||
reviewer,
|
||||
obligation,
|
||||
reviewCycleId,
|
||||
historyEventIds: uniqueIds([requestEventId, startedEventId]),
|
||||
...(requestEventId ? { reviewRequestEventId: requestEventId } : {}),
|
||||
...(historyEventTimestamp(openCycle.request)
|
||||
? { reviewRequestedAt: historyEventTimestamp(openCycle.request) }
|
||||
: {}),
|
||||
...(startedEventId ? { reviewStartedEventId: startedEventId } : {}),
|
||||
...(historyEventTimestamp(openCycle.started)
|
||||
? { reviewStartedAt: historyEventTimestamp(openCycle.started) }
|
||||
: {}),
|
||||
...(startedBy ? { reviewStartedBy: startedBy } : {}),
|
||||
canBypassPhase2,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCurrentReviewOwner(input: {
|
||||
|
|
@ -32,52 +225,11 @@ export function resolveCurrentReviewOwner(input: {
|
|||
kanbanReviewer?: string | null;
|
||||
historyEvents?: ReviewHistoryEventLike[];
|
||||
}): CurrentReviewOwner | null {
|
||||
if (input.reviewState !== 'review') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const historyEvents = [...(input.historyEvents ?? [])]
|
||||
.filter((event) =>
|
||||
[
|
||||
'review_requested',
|
||||
'review_started',
|
||||
'review_approved',
|
||||
'review_changes_requested',
|
||||
].includes(event.type)
|
||||
)
|
||||
.sort(compareEventsByTimestamp);
|
||||
|
||||
const latest = historyEvents.at(-1);
|
||||
if (latest?.type === 'review_approved' || latest?.type === 'review_changes_requested') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kanbanReviewer = normalizeMemberName(input.kanbanReviewer);
|
||||
if (kanbanReviewer) {
|
||||
return {
|
||||
reviewer: kanbanReviewer,
|
||||
historyEventIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const latestStarted = [...historyEvents]
|
||||
.reverse()
|
||||
.find((event) => event.type === 'review_started');
|
||||
const latestRequested = [...historyEvents]
|
||||
.reverse()
|
||||
.find((event) => event.type === 'review_requested');
|
||||
|
||||
const reviewer =
|
||||
normalizeMemberName(latestStarted?.actor) || normalizeMemberName(latestRequested?.reviewer);
|
||||
|
||||
if (!reviewer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reviewer,
|
||||
historyEventIds: [latestStarted?.id, latestRequested?.id].filter(
|
||||
(id): id is string => typeof id === 'string' && id.length > 0
|
||||
),
|
||||
};
|
||||
const cycle = resolveCurrentReviewCycle(input);
|
||||
return cycle
|
||||
? {
|
||||
reviewer: cycle.reviewer,
|
||||
historyEventIds: cycle.historyEventIds,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import {
|
|||
isTeamTaskTerminalForActionableWork,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
|
||||
import {
|
||||
normalizeMemberName,
|
||||
resolveCurrentReviewOwner,
|
||||
sameMemberName,
|
||||
} from '../../../core/domain';
|
||||
|
||||
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||
|
|
@ -144,12 +148,22 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
const reviewOwner =
|
||||
taskWorkflowColumn === 'review'
|
||||
? resolveCurrentReviewOwner({
|
||||
reviewState: task.reviewState,
|
||||
reviewState: taskWorkflowColumn,
|
||||
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
|
||||
historyEvents: task.historyEvents,
|
||||
})
|
||||
: null;
|
||||
addMember(reviewOwner?.reviewer);
|
||||
const selfReview =
|
||||
taskWorkflowColumn === 'review' &&
|
||||
Boolean(reviewOwner?.reviewer) &&
|
||||
Boolean(normalizeMemberName(task.owner)) &&
|
||||
sameMemberName(reviewOwner?.reviewer, task.owner);
|
||||
if (selfReview) {
|
||||
addLead();
|
||||
addDiagnostic('self_review_invalid');
|
||||
} else {
|
||||
addMember(reviewOwner?.reviewer);
|
||||
}
|
||||
|
||||
if (taskWorkflowColumn === 'review' && !reviewOwner?.reviewer) {
|
||||
addLead();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
|
|||
summary: 'Work sync check',
|
||||
source: 'system_notification',
|
||||
messageKind: input.payload.messageKind,
|
||||
workSyncIntent: input.payload.workSyncIntent,
|
||||
workSyncIntentKey: input.payload.workSyncIntentKey,
|
||||
workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ import type {
|
|||
MemberWorkSyncBusySignalPort,
|
||||
MemberWorkSyncLoggerPort,
|
||||
MemberWorkSyncNudgeDeliveryWakePort,
|
||||
MemberWorkSyncReviewPickupDeliveryPort,
|
||||
MemberWorkSyncReviewPickupEscalationPort,
|
||||
} from '../../core/application';
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
|
|
@ -59,6 +61,34 @@ import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaSt
|
|||
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
const STALE_STATUS_MAX_AGE_MS = 2 * 60_000;
|
||||
|
||||
function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
const evaluatedAtMs = Date.parse(status.evaluatedAt);
|
||||
if (!Number.isFinite(evaluatedAtMs)) {
|
||||
diagnostics.push('status_evaluated_at_invalid');
|
||||
} else if (
|
||||
status.agenda.items.length > 0 &&
|
||||
['needs_sync', 'still_working', 'blocked'].includes(status.state) &&
|
||||
nowMs - evaluatedAtMs > STALE_STATUS_MAX_AGE_MS
|
||||
) {
|
||||
diagnostics.push('status_stale_refresh_enqueued');
|
||||
}
|
||||
|
||||
const reportExpiresAtMs = Date.parse(status.report?.expiresAt ?? '');
|
||||
if (
|
||||
status.report?.accepted &&
|
||||
Number.isFinite(reportExpiresAtMs) &&
|
||||
reportExpiresAtMs <= nowMs &&
|
||||
(status.state === 'still_working' || status.state === 'blocked')
|
||||
) {
|
||||
diagnostics.push('accepted_report_lease_expired_refresh_enqueued');
|
||||
}
|
||||
|
||||
return [...new Set(diagnostics)];
|
||||
}
|
||||
|
||||
export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
|
||||
teamsBasePath: string;
|
||||
provider: RuntimeTurnSettledProvider;
|
||||
|
|
@ -100,6 +130,8 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
|
||||
extraBusySignals?: MemberWorkSyncBusySignalPort[];
|
||||
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
|
||||
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
|
||||
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}): MemberWorkSyncFeatureFacade {
|
||||
const clock = new SystemClockAdapter();
|
||||
|
|
@ -163,6 +195,8 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
watchdogCooldown,
|
||||
busySignal,
|
||||
...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}),
|
||||
...(deps.reviewPickupDelivery ? { reviewPickupDelivery: deps.reviewPickupDelivery } : {}),
|
||||
...(deps.reviewPickupEscalation ? { reviewPickupEscalation: deps.reviewPickupEscalation } : {}),
|
||||
reportToken,
|
||||
auditJournal,
|
||||
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
||||
|
|
@ -240,8 +274,27 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
runtimeTurnSettledDrainScheduler.start();
|
||||
nudgeDispatchScheduler?.start();
|
||||
|
||||
const readStatusWithStaleRefresh = async (
|
||||
request: MemberWorkSyncStatusRequest
|
||||
): Promise<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 {
|
||||
getStatus: (request) => diagnosticsReader.execute(request),
|
||||
getStatus: readStatusWithStaleRefresh,
|
||||
refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }),
|
||||
getMetrics: (request) => metricsReader.execute(request),
|
||||
report: (request) => reporter.execute(request),
|
||||
|
|
|
|||
|
|
@ -252,6 +252,30 @@ function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boo
|
|||
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(
|
||||
index: OutboxIndexFile,
|
||||
nowIso: string,
|
||||
|
|
@ -697,6 +721,30 @@ export class JsonMemberWorkSyncStore
|
|||
const current = outbox.items[input.id];
|
||||
if (current) {
|
||||
if (current.payloadHash !== input.payloadHash) {
|
||||
if (isSameReviewPickupIntent(current, input) && !isOutboxTerminal(current.status)) {
|
||||
const next: MemberWorkSyncOutboxItem = {
|
||||
...current,
|
||||
agendaFingerprint: input.agendaFingerprint,
|
||||
payloadHash: input.payloadHash,
|
||||
payload: input.payload,
|
||||
status: 'pending',
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
const nextAttemptAt = input.nextAttemptAt ?? current.nextAttemptAt;
|
||||
if (nextAttemptAt) {
|
||||
next.nextAttemptAt = nextAttemptAt;
|
||||
} else {
|
||||
delete next.nextAttemptAt;
|
||||
}
|
||||
delete next.claimedBy;
|
||||
delete next.claimedAt;
|
||||
delete next.lastError;
|
||||
outbox.items[input.id] = next;
|
||||
await this.writeMemberOutboxFile(input.teamName, input.memberName, outbox);
|
||||
await this.upsertOutboxIndexItem(input.teamName, next, memberKey);
|
||||
result = { ok: true, outcome: 'existing', item: next };
|
||||
return;
|
||||
}
|
||||
result = {
|
||||
ok: false,
|
||||
outcome: 'payload_conflict',
|
||||
|
|
@ -828,6 +876,10 @@ export class JsonMemberWorkSyncStore
|
|||
...current,
|
||||
status: 'delivered',
|
||||
deliveredMessageId: input.deliveredMessageId,
|
||||
...(input.deliveryState ? { deliveryState: input.deliveryState } : {}),
|
||||
...(input.deliveryDiagnostics?.length
|
||||
? { deliveryDiagnostics: input.deliveryDiagnostics }
|
||||
: {}),
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
delete next.lastError;
|
||||
|
|
@ -902,6 +954,32 @@ export class JsonMemberWorkSyncStore
|
|||
return Math.max(indexedCount, memberFileCount);
|
||||
}
|
||||
|
||||
async findDeliveredReviewPickupRequestEventIds(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
reviewRequestEventIds: string[];
|
||||
}): Promise<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> {
|
||||
return readJsonFile(
|
||||
this.paths.getLegacyStatusPath(teamName),
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idl
|
|||
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||
import { createHash } from 'crypto';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
|
@ -248,6 +249,7 @@ if (process.platform === 'win32') {
|
|||
|
||||
// --- Team message notification tracking ---
|
||||
const teamInboxReader = new TeamInboxReader();
|
||||
const teamInboxWriter = new TeamInboxWriter();
|
||||
const sentMessagesStore = new TeamSentMessagesStore();
|
||||
/** Track last-seen message count per inbox file to detect new messages. */
|
||||
const inboxMessageCounts = new Map<string, number>();
|
||||
|
|
@ -256,9 +258,58 @@ const sentMessageCounts = new Map<string, number>();
|
|||
/** Debounce per-inbox to avoid flooding during batch writes. */
|
||||
const inboxNotifyTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const INBOX_NOTIFY_DEBOUNCE_MS = 500;
|
||||
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
|
||||
/** Messages sent from our UI (user_sent) - suppress notifications for these. */
|
||||
const suppressedSources = new Set(['user_sent']);
|
||||
|
||||
function buildMemberWorkSyncReviewPickupEscalationMessageId(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
reason: string;
|
||||
reviewRequestEventIds?: readonly string[];
|
||||
taskRefs: readonly { taskId: string; displayId?: string }[];
|
||||
}): string {
|
||||
const stableKey = JSON.stringify({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName.trim().toLowerCase(),
|
||||
reason: input.reason,
|
||||
reviewRequestEventIds: [...new Set(input.reviewRequestEventIds ?? [])].sort(),
|
||||
taskIds: [...new Set(input.taskRefs.map((taskRef) => taskRef.taskId).filter(Boolean))].sort(),
|
||||
});
|
||||
const digest = createHash('sha256').update(stableKey).digest('hex').slice(0, 20);
|
||||
return `member-work-sync-review-pickup-escalation:${digest}`;
|
||||
}
|
||||
|
||||
function buildMemberWorkSyncReviewPickupEscalationText(input: {
|
||||
memberName: string;
|
||||
reason: string;
|
||||
diagnostics?: readonly string[];
|
||||
taskRefs: readonly { taskId: string; displayId?: string }[];
|
||||
}): string {
|
||||
const taskLines = input.taskRefs.length
|
||||
? input.taskRefs
|
||||
.map(
|
||||
(taskRef) => `- ${taskRef.displayId ?? taskRef.taskId.slice(0, 8)} (${taskRef.taskId})`
|
||||
)
|
||||
.join('\n')
|
||||
: '- No task refs recorded';
|
||||
const diagnostics = [...new Set(input.diagnostics ?? [])].filter(Boolean);
|
||||
return [
|
||||
'Review pickup still pending in member work-sync.',
|
||||
'',
|
||||
`Reviewer: ${input.memberName}`,
|
||||
`Reason: ${input.reason}`,
|
||||
'',
|
||||
'Tasks:',
|
||||
taskLines,
|
||||
'',
|
||||
'No review_start, review_approve, or review_request_changes was recorded for the current review request after the member correction path.',
|
||||
'Consider reassigning the reviewer or sending a direct instruction.',
|
||||
diagnostics.length ? `Diagnostics: ${diagnostics.join(', ')}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createOpenCodeRuntimeAdapterRegistry(
|
||||
reportProgress: (phase: string, message: string) => void = () => undefined
|
||||
): Promise<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'),
|
||||
});
|
||||
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
|
|||
|
||||
const wrapReviewHandler = createIpcWrapper('IPC:review');
|
||||
const logger = createLogger('IPC:review');
|
||||
const TEAM_TASK_CHANGE_SUMMARY_IPC_RAW_REQUEST_LIMIT = 1_000;
|
||||
const TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT = 201;
|
||||
|
||||
// --- Module-level state ---
|
||||
|
|
@ -212,7 +213,7 @@ function sanitizeTeamTaskChangeSummaryRequests(requests: unknown): TeamTaskChang
|
|||
|
||||
const sanitizedRequests: TeamTaskChangeSummaryRequest[] = [];
|
||||
const seenTaskIds = new Set<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) {
|
||||
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_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const;
|
||||
const OPEN_CODE_MAX_DISCOVERED_LANES = 500;
|
||||
const TEAM_TASK_CHANGE_SUMMARY_BATCH_INSPECT_LIMIT = 1_000;
|
||||
const TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT = 200;
|
||||
const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3;
|
||||
|
||||
|
|
@ -228,14 +229,13 @@ export class ChangeExtractorService {
|
|||
|
||||
const ledgerResult = await this.readLedgerTaskChanges(resolvedInput);
|
||||
if (ledgerResult) {
|
||||
await this.recordTaskChangePresence(
|
||||
teamName,
|
||||
taskId,
|
||||
taskMeta,
|
||||
effectiveOptions,
|
||||
const recoveredLedgerResult = await this.recoverWarningOnlyLedgerResult(
|
||||
resolvedInput,
|
||||
ledgerResult
|
||||
);
|
||||
return ledgerResult;
|
||||
const result = recoveredLedgerResult ?? ledgerResult;
|
||||
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput);
|
||||
|
|
@ -339,7 +339,9 @@ export class ChangeExtractorService {
|
|||
const inputRequests = Array.isArray(requests) ? requests : [];
|
||||
const seenTaskIds = new Set<string>();
|
||||
const uniqueRequests: TeamTaskChangeSummaryRequest[] = [];
|
||||
for (const request of inputRequests) {
|
||||
let inspectedRequests = 0;
|
||||
for (const request of inputRequests.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_INSPECT_LIMIT)) {
|
||||
inspectedRequests += 1;
|
||||
if (!request || typeof request !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -349,6 +351,9 @@ export class ChangeExtractorService {
|
|||
}
|
||||
seenTaskIds.add(taskId);
|
||||
uniqueRequests.push({ ...request, taskId });
|
||||
if (uniqueRequests.length > TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const cappedRequests = uniqueRequests.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT);
|
||||
const items: TeamTaskChangeSummaryItem[] = cappedRequests.map((request) => ({
|
||||
|
|
@ -391,7 +396,10 @@ export class ChangeExtractorService {
|
|||
teamName,
|
||||
items,
|
||||
computedAt: new Date().toISOString(),
|
||||
truncated: uniqueRequests.length > cappedRequests.length || undefined,
|
||||
truncated:
|
||||
uniqueRequests.length > cappedRequests.length || inspectedRequests < inputRequests.length
|
||||
? true
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -471,6 +479,106 @@ export class ChangeExtractorService {
|
|||
}
|
||||
}
|
||||
|
||||
private async recoverWarningOnlyLedgerResult(
|
||||
input: ResolvedTaskChangeComputeInput,
|
||||
ledgerResult: TaskChangeSetV2
|
||||
): Promise<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(
|
||||
input: ResolvedTaskChangeComputeInput
|
||||
): Promise<OpenCodeBackfillAttempt> {
|
||||
|
|
|
|||
|
|
@ -2312,6 +2312,9 @@ export class TeamDataService {
|
|||
toolSummary: enrichedRequest.toolSummary,
|
||||
toolCalls: enrichedRequest.toolCalls,
|
||||
messageKind: enrichedRequest.messageKind,
|
||||
workSyncIntent: enrichedRequest.workSyncIntent,
|
||||
workSyncIntentKey: enrichedRequest.workSyncIntentKey,
|
||||
workSyncReviewRequestEventIds: enrichedRequest.workSyncReviewRequestEventIds,
|
||||
slashCommand: enrichedRequest.slashCommand,
|
||||
commandOutput: enrichedRequest.commandOutput,
|
||||
taskRefs: enrichedRequest.taskRefs,
|
||||
|
|
|
|||
|
|
@ -148,6 +148,17 @@ export class TeamInboxReader {
|
|||
: row.messageKind === 'default'
|
||||
? 'default'
|
||||
: undefined,
|
||||
workSyncIntent:
|
||||
row.workSyncIntent === 'agenda_sync' || row.workSyncIntent === 'review_pickup'
|
||||
? row.workSyncIntent
|
||||
: undefined,
|
||||
workSyncIntentKey:
|
||||
typeof row.workSyncIntentKey === 'string' ? row.workSyncIntentKey : undefined,
|
||||
workSyncReviewRequestEventIds: Array.isArray(row.workSyncReviewRequestEventIds)
|
||||
? row.workSyncReviewRequestEventIds.filter(
|
||||
(id): id is string => typeof id === 'string' && id.length > 0
|
||||
)
|
||||
: undefined,
|
||||
slashCommand:
|
||||
row.slashCommand &&
|
||||
typeof row.slashCommand === 'object' &&
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ export class TeamInboxWriter {
|
|||
...(request.toolSummary && { toolSummary: request.toolSummary }),
|
||||
...(request.toolCalls && { toolCalls: request.toolCalls }),
|
||||
...(request.messageKind && { messageKind: request.messageKind }),
|
||||
...(request.workSyncIntent && { workSyncIntent: request.workSyncIntent }),
|
||||
...(request.workSyncIntentKey && { workSyncIntentKey: request.workSyncIntentKey }),
|
||||
...(request.workSyncReviewRequestEventIds?.length
|
||||
? { workSyncReviewRequestEventIds: request.workSyncReviewRequestEventIds }
|
||||
: {}),
|
||||
...(request.slashCommand && { slashCommand: request.slashCommand }),
|
||||
...(request.commandOutput && { commandOutput: request.commandOutput }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import {
|
||||
AgentAttachmentError,
|
||||
buildClaudeAttachmentDeliveryParts,
|
||||
buildCodexNativeAttachmentDeliveryParts,
|
||||
buildOpenCodeAttachmentDeliveryParts,
|
||||
type CodexNativeImageArgPart,
|
||||
type OpenCodeFilePart,
|
||||
} from '@features/agent-attachments/main';
|
||||
import { AgentAttachmentError } from '@features/agent-attachments/core/domain';
|
||||
import {
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
|
|
@ -74,9 +74,9 @@ import {
|
|||
} from '@shared/constants/crossTeam';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import {
|
||||
DEFAULT_TOOL_APPROVAL_SETTINGS,
|
||||
type AttachmentMeta,
|
||||
type AttachmentPayload,
|
||||
DEFAULT_TOOL_APPROVAL_SETTINGS,
|
||||
} from '@shared/types/team';
|
||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
||||
|
|
@ -203,8 +203,8 @@ import {
|
|||
decideOpenCodeRuntimeDeliveryAdvisory,
|
||||
isDeferredGenericOpenCodeRuntimeDeliveryReason,
|
||||
isPotentialOpenCodeRuntimeDeliveryError,
|
||||
toOpenCodeRuntimeDeliveryUserVisibleImpact,
|
||||
type OpenCodeRuntimeDeliveryAdvisoryDecision,
|
||||
toOpenCodeRuntimeDeliveryUserVisibleImpact,
|
||||
} from './opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy';
|
||||
import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
|
||||
import {
|
||||
|
|
@ -285,6 +285,7 @@ import {
|
|||
buildDesktopTeammateModeCliArgs,
|
||||
resolveDesktopTeammateModeDecision,
|
||||
} from './runtimeTeammateMode';
|
||||
import { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||
import {
|
||||
choosePreferredLaunchSnapshot,
|
||||
clearBootstrapState,
|
||||
|
|
@ -293,9 +294,9 @@ import {
|
|||
readBootstrapRuntimeState,
|
||||
} from './TeamBootstrapStateReader';
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
import { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
import { TeamInboxWriter } from './TeamInboxWriter';
|
||||
import { writeTeamLaunchFailureArtifactPack } from './TeamLaunchFailureArtifactPack';
|
||||
import {
|
||||
createPersistedLaunchSnapshot,
|
||||
deriveTeamLaunchAggregateState,
|
||||
|
|
@ -304,7 +305,6 @@ import {
|
|||
snapshotFromRuntimeMemberStatuses,
|
||||
snapshotToMemberSpawnStatuses,
|
||||
} from './TeamLaunchStateEvaluator';
|
||||
import { writeTeamLaunchFailureArtifactPack } from './TeamLaunchFailureArtifactPack';
|
||||
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
|
|
@ -5406,7 +5406,7 @@ interface LiveInboxRelayResult {
|
|||
|
||||
interface OpenCodeMemberInboxRelayOptions {
|
||||
onlyMessageId?: string;
|
||||
source?: 'watcher' | 'ui-send' | 'manual' | 'watchdog';
|
||||
source?: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup';
|
||||
deliveryMetadata?: {
|
||||
replyRecipient?: string;
|
||||
actionMode?: AgentActionMode;
|
||||
|
|
@ -6967,7 +6967,10 @@ export class TeamProvisioningService {
|
|||
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
|
||||
if (
|
||||
ledgerRecord?.messageKind === 'member_work_sync_nudge' &&
|
||||
normalized === 'member_work_sync_report'
|
||||
(normalized === 'member_work_sync_report' ||
|
||||
normalized === 'review_start' ||
|
||||
normalized === 'review_approve' ||
|
||||
normalized === 'review_request_changes')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -7195,6 +7198,7 @@ export class TeamProvisioningService {
|
|||
inboxMessageId: record.inboxMessageId,
|
||||
replyRecipient: record.replyRecipient,
|
||||
messageKind: record.messageKind,
|
||||
workSyncIntent: record.workSyncIntent,
|
||||
actionMode: record.actionMode,
|
||||
taskRefs: record.taskRefs,
|
||||
status: record.status,
|
||||
|
|
@ -7875,6 +7879,8 @@ export class TeamProvisioningService {
|
|||
replyRecipient?: string | null;
|
||||
actionMode?: AgentActionMode;
|
||||
messageKind?: OpenCodeTeamRuntimeMessageInput['messageKind'];
|
||||
workSyncIntent?: OpenCodeTeamRuntimeMessageInput['workSyncIntent'];
|
||||
workSyncReviewRequestEventIds?: string[];
|
||||
taskRefs?: TaskRef[];
|
||||
promptAccepted: boolean;
|
||||
visibleReply?: OpenCodeVisibleReplyProof | null;
|
||||
|
|
@ -7922,6 +7928,8 @@ export class TeamProvisioningService {
|
|||
replyRecipient: input.replyRecipient ?? undefined,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
workSyncIntent: input.workSyncIntent,
|
||||
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
|
||||
taskRefs: input.taskRefs,
|
||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||
});
|
||||
|
|
@ -8424,34 +8432,43 @@ export class TeamProvisioningService {
|
|||
}> {
|
||||
const memberKey = record.memberName.trim().toLowerCase();
|
||||
let recordsForMember: OpenCodePromptDeliveryLedgerRecord[] = [record];
|
||||
let ledgerReadSucceeded = false;
|
||||
try {
|
||||
const laneRecords = await this.createOpenCodePromptDeliveryLedger(
|
||||
record.teamName,
|
||||
record.laneId
|
||||
).list();
|
||||
ledgerReadSucceeded = true;
|
||||
recordsForMember = laneRecords.filter(
|
||||
(candidate) => candidate.memberName.trim().toLowerCase() === memberKey
|
||||
);
|
||||
} catch {
|
||||
recordsForMember = [record];
|
||||
}
|
||||
const latestRecord = recordsForMember.find((candidate) => candidate.id === record.id) ?? record;
|
||||
const latestRecord = recordsForMember.find((candidate) => candidate.id === record.id) ?? null;
|
||||
if (!latestRecord && ledgerReadSucceeded) {
|
||||
return {
|
||||
record,
|
||||
decision: { action: 'suppress' },
|
||||
};
|
||||
}
|
||||
const recordForDecision = latestRecord ?? record;
|
||||
const recordsByMember = new Map<string, readonly OpenCodePromptDeliveryLedgerRecord[]>([
|
||||
[memberKey, recordsForMember.length > 0 ? recordsForMember : [latestRecord]],
|
||||
[memberKey, recordsForMember.length > 0 ? recordsForMember : [recordForDecision]],
|
||||
]);
|
||||
const activeMemberKeys = new Set([memberKey]);
|
||||
const proofIndex = await this.openCodeRuntimeDeliveryProofReader
|
||||
.readProofIndex({
|
||||
teamName: latestRecord.teamName,
|
||||
teamName: recordForDecision.teamName,
|
||||
activeMemberKeys,
|
||||
recordsByMember,
|
||||
})
|
||||
.catch(() => null);
|
||||
return {
|
||||
record: latestRecord,
|
||||
record: recordForDecision,
|
||||
decision: decideOpenCodeRuntimeDeliveryAdvisory({
|
||||
record: latestRecord,
|
||||
proof: proofIndex?.getSnapshot(latestRecord.memberName, latestRecord),
|
||||
record: recordForDecision,
|
||||
proof: proofIndex?.getSnapshot(recordForDecision.memberName, recordForDecision),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -8592,13 +8609,14 @@ export class TeamProvisioningService {
|
|||
selectOpenCodeRuntimeDeliveryReason(record) ??
|
||||
record.responseState ??
|
||||
record.status;
|
||||
const action = decision ? `${decision.action}:${decision.severity ?? 'none'}` : 'record:none';
|
||||
const normalized = reason
|
||||
.toLowerCase()
|
||||
.replace(/https?:\/\/\S+/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 96);
|
||||
return normalized || 'unknown';
|
||||
return `${action}:${normalized || 'unknown'}`;
|
||||
}
|
||||
|
||||
private scheduleOpenCodeRuntimeDeliveryAdvisoryReview(
|
||||
|
|
@ -8773,6 +8791,7 @@ export class TeamProvisioningService {
|
|||
replyRecipient,
|
||||
actionMode: message.actionMode ?? null,
|
||||
messageKind: message.messageKind ?? null,
|
||||
workSyncIntent: message.workSyncIntent ?? null,
|
||||
taskRefs: message.taskRefs ?? [],
|
||||
payloadHash: hashOpenCodePromptDeliveryPayload({
|
||||
text: message.text,
|
||||
|
|
@ -8817,6 +8836,8 @@ export class TeamProvisioningService {
|
|||
replyRecipient?: string;
|
||||
actionMode?: AgentActionMode;
|
||||
messageKind?: InboxMessage['messageKind'];
|
||||
workSyncIntent?: InboxMessage['workSyncIntent'];
|
||||
workSyncReviewRequestEventIds?: string[];
|
||||
taskRefs?: TaskRef[];
|
||||
attachments?: AttachmentPayload[];
|
||||
source?: OpenCodeMemberInboxRelayOptions['source'];
|
||||
|
|
@ -9042,6 +9063,8 @@ export class TeamProvisioningService {
|
|||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
workSyncIntent: input.workSyncIntent,
|
||||
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
|
||||
taskRefs: input.taskRefs,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
|
|
@ -9149,6 +9172,7 @@ export class TeamProvisioningService {
|
|||
replyRecipient: input.replyRecipient ?? 'user',
|
||||
actionMode: input.actionMode ?? null,
|
||||
messageKind: input.messageKind ?? null,
|
||||
workSyncIntent: input.workSyncIntent ?? null,
|
||||
taskRefs: input.taskRefs ?? [],
|
||||
payloadHash: hashOpenCodePromptDeliveryPayload({
|
||||
text: input.text,
|
||||
|
|
@ -9288,6 +9312,8 @@ export class TeamProvisioningService {
|
|||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
workSyncIntent: input.workSyncIntent,
|
||||
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
|
||||
taskRefs: input.taskRefs,
|
||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||
});
|
||||
|
|
@ -9439,6 +9465,8 @@ export class TeamProvisioningService {
|
|||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
workSyncIntent: input.workSyncIntent,
|
||||
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
|
||||
taskRefs: input.taskRefs,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
|
|
@ -9497,6 +9525,8 @@ export class TeamProvisioningService {
|
|||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
workSyncIntent: input.workSyncIntent,
|
||||
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
|
||||
taskRefs: input.taskRefs,
|
||||
promptAccepted,
|
||||
visibleReply: proof.visibleReply,
|
||||
|
|
@ -11019,6 +11049,9 @@ export class TeamProvisioningService {
|
|||
toolSummary: message.toolSummary,
|
||||
toolCalls: message.toolCalls,
|
||||
messageKind: message.messageKind,
|
||||
workSyncIntent: message.workSyncIntent,
|
||||
workSyncIntentKey: message.workSyncIntentKey,
|
||||
workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds,
|
||||
slashCommand: message.slashCommand,
|
||||
commandOutput: message.commandOutput,
|
||||
});
|
||||
|
|
@ -11050,6 +11083,9 @@ export class TeamProvisioningService {
|
|||
toolSummary: message.toolSummary,
|
||||
toolCalls: message.toolCalls,
|
||||
messageKind: message.messageKind,
|
||||
workSyncIntent: message.workSyncIntent,
|
||||
workSyncIntentKey: message.workSyncIntentKey,
|
||||
workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds,
|
||||
slashCommand: message.slashCommand,
|
||||
commandOutput: message.commandOutput,
|
||||
});
|
||||
|
|
@ -21278,6 +21314,7 @@ export class TeamProvisioningService {
|
|||
replyRecipient: effectiveReplyRecipient,
|
||||
actionMode: effectiveActionMode ?? null,
|
||||
messageKind: message.messageKind ?? null,
|
||||
workSyncIntent: message.workSyncIntent ?? null,
|
||||
taskRefs: effectiveTaskRefs,
|
||||
payloadHash: hashOpenCodePromptDeliveryPayload({
|
||||
text: message.text,
|
||||
|
|
@ -21332,6 +21369,8 @@ export class TeamProvisioningService {
|
|||
replyRecipient: effectiveReplyRecipient,
|
||||
actionMode: effectiveActionMode ?? undefined,
|
||||
messageKind: message.messageKind,
|
||||
workSyncIntent: message.workSyncIntent,
|
||||
workSyncReviewRequestEventIds: message.workSyncReviewRequestEventIds,
|
||||
taskRefs: effectiveTaskRefs,
|
||||
attachments: attachmentPayloads.attachments,
|
||||
source: effectiveSource,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
OpenCodeDeliveryResponseState,
|
||||
OpenCodeDeliveryVisibleReplyCorrelation,
|
||||
} from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
import type { AgentActionMode, InboxMessage, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
|
||||
export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1;
|
||||
export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
|
@ -31,8 +31,9 @@ export interface OpenCodePromptDeliveryLedgerRecord {
|
|||
runtimeSessionId: string | null;
|
||||
inboxMessageId: string;
|
||||
inboxTimestamp: string;
|
||||
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog';
|
||||
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog' | 'member-work-sync-review-pickup';
|
||||
messageKind: InboxMessageKind | null;
|
||||
workSyncIntent?: InboxMessage['workSyncIntent'] | null;
|
||||
replyRecipient: string;
|
||||
actionMode: AgentActionMode | null;
|
||||
taskRefs: TaskRef[];
|
||||
|
|
@ -99,6 +100,7 @@ const OPENCODE_PROMPT_DELIVERY_SOURCES = new Set<OpenCodePromptDeliveryLedgerRec
|
|||
'ui-send',
|
||||
'manual',
|
||||
'watchdog',
|
||||
'member-work-sync-review-pickup',
|
||||
]);
|
||||
|
||||
const OPENCODE_DELIVERY_VISIBLE_REPLY_CORRELATIONS =
|
||||
|
|
@ -119,6 +121,7 @@ export interface EnsureOpenCodePromptDeliveryInput {
|
|||
inboxTimestamp: string;
|
||||
source: OpenCodePromptDeliveryLedgerRecord['source'];
|
||||
messageKind?: InboxMessageKind | null;
|
||||
workSyncIntent?: InboxMessage['workSyncIntent'] | null;
|
||||
replyRecipient: string;
|
||||
actionMode?: AgentActionMode | null;
|
||||
taskRefs?: TaskRef[];
|
||||
|
|
@ -181,6 +184,16 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
const updated: OpenCodePromptDeliveryLedgerRecord = {
|
||||
...existing,
|
||||
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,
|
||||
};
|
||||
result = updated;
|
||||
|
|
@ -201,6 +214,7 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
inboxTimestamp: input.inboxTimestamp,
|
||||
source: input.source,
|
||||
messageKind: input.messageKind ?? null,
|
||||
workSyncIntent: input.workSyncIntent ?? null,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode ?? null,
|
||||
taskRefs: input.taskRefs ?? [],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { OpenCodePromptDeliveryStatus } from './OpenCodePromptDeliveryLedger';
|
||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
import type { AgentActionMode, InboxMessage, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
|
||||
export type OpenCodePromptDeliveryRepairKind =
|
||||
| 'none'
|
||||
|
|
@ -26,6 +26,7 @@ export interface OpenCodePromptDeliveryRepairInput {
|
|||
inboxMessageId: string;
|
||||
replyRecipient: string;
|
||||
messageKind: InboxMessageKind | null;
|
||||
workSyncIntent?: InboxMessage['workSyncIntent'] | null;
|
||||
actionMode: AgentActionMode | null;
|
||||
taskRefs: TaskRef[];
|
||||
status: OpenCodePromptDeliveryStatus;
|
||||
|
|
@ -52,6 +53,12 @@ const SIDE_EFFECT_TOOL_NAMES = new Set([
|
|||
'multi_edit',
|
||||
]);
|
||||
|
||||
const REVIEW_WORKFLOW_TOOL_NAMES = new Set([
|
||||
'review_start',
|
||||
'review_approve',
|
||||
'review_request_changes',
|
||||
]);
|
||||
|
||||
function none(reason: string): OpenCodePromptDeliveryRepairDecision {
|
||||
return { kind: 'none', retryable: false, controlText: null, reason };
|
||||
}
|
||||
|
|
@ -96,7 +103,11 @@ function hasTool(tools: Set<string>, toolName: string): boolean {
|
|||
|
||||
function hasTaskTool(tools: Set<string>): boolean {
|
||||
for (const tool of tools) {
|
||||
if (tool.startsWith('task_') || tool === 'runtime_task_event') {
|
||||
if (
|
||||
tool.startsWith('task_') ||
|
||||
REVIEW_WORKFLOW_TOOL_NAMES.has(tool) ||
|
||||
tool === 'runtime_task_event'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +148,16 @@ function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): stri
|
|||
|
||||
function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
|
||||
const taskIds = taskIdList(input.taskRefs);
|
||||
if (input.workSyncIntent === 'review_pickup') {
|
||||
return [
|
||||
'This is a targeted member-work-sync review pickup control message. A plain acknowledgement is not sufficient proof.',
|
||||
'Open the current task, verify reviewState/status, then start or continue the review only if it is still assigned to you.',
|
||||
'Do not mark the review complete from this retry text alone.',
|
||||
`If you cannot pick up the review now, call agent-teams_member_work_sync_status or mcp__agent-teams__member_work_sync_status with teamName="${input.teamName}" and memberName="${input.memberName}", then report state "blocked" or "still_working" only for the real current state.`,
|
||||
taskIds ? `Relevant taskIds: ${taskIds}.` : null,
|
||||
'Do not invent or reuse a raw report token from this retry text.',
|
||||
].filter((line): line is string => line !== null);
|
||||
}
|
||||
return [
|
||||
'This is a member-work-sync control message. A plain acknowledgement is not sufficient proof.',
|
||||
`Call agent-teams_member_work_sync_status or mcp__agent-teams__member_work_sync_status with teamName="${input.teamName}" and memberName="${input.memberName}".`,
|
||||
|
|
@ -163,7 +184,9 @@ function noAssistantControlLines(input: OpenCodePromptDeliveryRepairInput): stri
|
|||
'The app saw the prompt but did not observe assistant response proof.',
|
||||
'You must not end this turn empty.',
|
||||
input.messageKind === 'member_work_sync_nudge'
|
||||
? 'Follow the member-work-sync status/report instructions for this message.'
|
||||
? input.workSyncIntent === 'review_pickup'
|
||||
? 'Follow the member-work-sync review pickup instructions for this message.'
|
||||
: 'Follow the member-work-sync status/report instructions for this message.'
|
||||
: `Send a concrete reply using message_send with relayOfMessageId="${input.inboxMessageId}", or provide a concrete plain-text answer only if message_send is unavailable.`,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
} from './TeamRuntimeAdapter';
|
||||
import type {
|
||||
AgentActionMode,
|
||||
InboxMessage,
|
||||
InboxMessageKind,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
TaskRef,
|
||||
|
|
@ -65,6 +66,8 @@ export interface OpenCodeTeamRuntimeMessageInput {
|
|||
replyRecipient?: string;
|
||||
actionMode?: AgentActionMode;
|
||||
messageKind?: InboxMessageKind;
|
||||
workSyncIntent?: InboxMessage['workSyncIntent'];
|
||||
workSyncReviewRequestEventIds?: string[];
|
||||
taskRefs?: TaskRef[];
|
||||
bootstrapCheckinRetry?: {
|
||||
runtimeSessionId: string;
|
||||
|
|
@ -900,10 +903,15 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
memberName: input.memberName,
|
||||
inboundMessageId: input.messageId,
|
||||
...(input.messageKind ? { messageKind: input.messageKind } : {}),
|
||||
...(input.workSyncIntent ? { workSyncIntent: input.workSyncIntent } : {}),
|
||||
...(input.workSyncReviewRequestEventIds?.length
|
||||
? { workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds }
|
||||
: {}),
|
||||
taskRefs: input.taskRefs,
|
||||
})
|
||||
: null;
|
||||
const isWorkSyncNudge = input.messageKind === 'member_work_sync_nudge';
|
||||
const isReviewPickupNudge = isWorkSyncNudge && input.workSyncIntent === 'review_pickup';
|
||||
const taskIds =
|
||||
input.taskRefs
|
||||
?.map((ref) => ref.taskId?.trim())
|
||||
|
|
@ -912,33 +920,44 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
// message_send reply here causes false delivery failures, so accept the
|
||||
// dedicated member_work_sync_report proof path while keeping normal user
|
||||
// messages on the visible reply contract.
|
||||
const responseInstructions = isWorkSyncNudge
|
||||
const responseInstructions = isReviewPickupNudge
|
||||
? [
|
||||
'This delivered app message is a member-work-sync nudge.',
|
||||
'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
||||
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with teamName="${input.teamName}" and memberName="${input.memberName}".`,
|
||||
`Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with teamName="${input.teamName}", memberName="${input.memberName}", the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`,
|
||||
taskIds.length
|
||||
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
|
||||
: null,
|
||||
'This delivered app message is a targeted member-work-sync review pickup nudge.',
|
||||
'Process the current review request now if it is still assigned to you. Open the task, verify reviewState/status, then use the review workflow tools to start or continue the review.',
|
||||
'Do not mark the review complete from this prompt alone.',
|
||||
'A visible agent-teams_message_send reply is optional. Concrete review progress, review tool usage, or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
||||
`If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with teamName="${input.teamName}" and memberName="${input.memberName}", then report state "blocked" or "still_working" only for the real current state.`,
|
||||
taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null,
|
||||
`Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`,
|
||||
'Do not reply only with acknowledgement.',
|
||||
]
|
||||
: [
|
||||
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).',
|
||||
`Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
|
||||
'Include source="runtime_delivery" in that message_send call.',
|
||||
input.messageId
|
||||
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
||||
: null,
|
||||
input.taskRefs?.length
|
||||
? `If taskRefs are present in <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.',
|
||||
];
|
||||
: isWorkSyncNudge
|
||||
? [
|
||||
'This delivered app message is a member-work-sync nudge.',
|
||||
'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
||||
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with teamName="${input.teamName}" and memberName="${input.memberName}".`,
|
||||
`Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with teamName="${input.teamName}", memberName="${input.memberName}", the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`,
|
||||
taskIds.length
|
||||
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
|
||||
: null,
|
||||
`Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`,
|
||||
'Do not reply only with acknowledgement.',
|
||||
]
|
||||
: [
|
||||
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).',
|
||||
`Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
|
||||
'Include source="runtime_delivery" in that message_send call.',
|
||||
input.messageId
|
||||
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
||||
: null,
|
||||
input.taskRefs?.length
|
||||
? `If taskRefs are present in <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 [
|
||||
'<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 { useStore } from '@renderer/store';
|
||||
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, FileDiff, GitCompareArrows, Loader2, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { FileIcon } from './editor/FileIcon';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
import {
|
||||
buildTeamChangeRequestPlan,
|
||||
buildTeamChangesTasksFingerprint,
|
||||
getTeamChangeTaskTimeMs,
|
||||
TEAM_CHANGES_MAX_RENDERED_FILE_ROWS,
|
||||
} from './teamChangesRequestPlan';
|
||||
import { type TeamChangeSummaryState, useTeamChangesSummaries } from './useTeamChangesSummaries';
|
||||
|
||||
import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
|
||||
|
||||
interface TeamChangesSectionProps {
|
||||
teamName: string;
|
||||
tasks: TeamTaskWithKanban[];
|
||||
onViewChanges: (taskId: string, filePath?: string) => void;
|
||||
}
|
||||
|
||||
interface TeamChangeSummaryState {
|
||||
taskId: string;
|
||||
changeSet: TaskChangeSetV2 | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TeamChangeStats {
|
||||
eligibleCount: number;
|
||||
requestedCount: number;
|
||||
deferredCount: number;
|
||||
}
|
||||
|
||||
interface TeamChangesLoadOptions {
|
||||
forceFresh?: boolean;
|
||||
showSpinner?: boolean;
|
||||
preserveOnError?: boolean;
|
||||
interface RenderedTeamChangeSummary {
|
||||
summary: TeamChangeSummaryState;
|
||||
task: TeamTaskWithKanban;
|
||||
visibleFiles: FileChangeSummary[];
|
||||
fileBudget: number;
|
||||
}
|
||||
|
||||
function getTaskChangeContributors(
|
||||
|
|
@ -71,7 +54,7 @@ function getTaskChangeContributors(
|
|||
|
||||
function getVisibleFileName(file: FileChangeSummary): string {
|
||||
const value = file.relativePath || file.filePath;
|
||||
return value.split('/').pop() ?? value;
|
||||
return value.split(/[\\/]/).pop() ?? value;
|
||||
}
|
||||
|
||||
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
|
||||
|
|
@ -86,31 +69,15 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
tasks,
|
||||
onViewChanges,
|
||||
}: TeamChangesSectionProps): React.JSX.Element {
|
||||
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
|
||||
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
|
||||
const [sectionOpen, setSectionOpen] = useState(false);
|
||||
const [summariesByTaskId, setSummariesByTaskId] = useState<
|
||||
Record<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 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 { summariesByTaskId, stats, loading, refreshing, error, refresh } = useTeamChangesSummaries(
|
||||
{
|
||||
teamName,
|
||||
tasks,
|
||||
sectionOpen,
|
||||
}
|
||||
);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
sectionOpenRef.current = sectionOpen;
|
||||
|
||||
const visibleSummaries = useMemo(() => {
|
||||
return Object.values(summariesByTaskId)
|
||||
|
|
@ -131,195 +98,18 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
);
|
||||
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
|
||||
const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined;
|
||||
|
||||
const loadSummaries = useCallback(
|
||||
async ({
|
||||
forceFresh = false,
|
||||
showSpinner = false,
|
||||
preserveOnError = true,
|
||||
}: TeamChangesLoadOptions = {}): Promise<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 (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);
|
||||
const renderedSummaries = useMemo(() => {
|
||||
const entries: RenderedTeamChangeSummary[] = [];
|
||||
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
|
||||
for (const entry of visibleSummaries) {
|
||||
const files = entry.summary.changeSet?.files ?? [];
|
||||
const fileBudget = Math.max(0, remainingFileRows);
|
||||
const visibleFiles = files.slice(0, fileBudget);
|
||||
entries.push({ ...entry, visibleFiles, fileBudget });
|
||||
remainingFileRows -= visibleFiles.length;
|
||||
}
|
||||
}, [sectionOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen || hasLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasLoadedRef.current = true;
|
||||
lastRequestedTasksFingerprintRef.current = tasksFingerprint;
|
||||
void loadSummaries({ showSpinner: true, preserveOnError: false });
|
||||
}, [loadSummaries, sectionOpen, tasksFingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen || !hasLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (lastRequestedTasksFingerprintRef.current === tasksFingerprint) {
|
||||
return;
|
||||
}
|
||||
lastRequestedTasksFingerprintRef.current = tasksFingerprint;
|
||||
void loadSummaries({ showSpinner: false, preserveOnError: true });
|
||||
}, [loadSummaries, sectionOpen, tasksFingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen || activeRequestSeqRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
const options = queuedRefreshOptionsRef.current;
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
void loadSummaries(options);
|
||||
}, [loadSummaries, queuedRefreshTick, sectionOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
void loadSummaries({ showSpinner: false, preserveOnError: true });
|
||||
}, TEAM_CHANGES_AUTO_REFRESH_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadSummaries, sectionOpen]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false });
|
||||
}, [loadSummaries]);
|
||||
|
||||
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
|
||||
return entries;
|
||||
}, [visibleSummaries]);
|
||||
|
||||
return (
|
||||
<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"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRefresh();
|
||||
refresh();
|
||||
}}
|
||||
disabled={loading || refreshing}
|
||||
aria-label="Refresh team changes"
|
||||
|
|
@ -370,12 +160,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
) : visibleSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<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 files = changeSet?.files ?? [];
|
||||
const fileBudget = Math.max(0, remainingFileRows);
|
||||
const visibleFiles = files.slice(0, fileBudget);
|
||||
remainingFileRows -= visibleFiles.length;
|
||||
const contributors = getTaskChangeContributors(task, changeSet);
|
||||
const contributorLabel =
|
||||
contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||
import {
|
||||
buildTeamChangeRequestPlan,
|
||||
buildTeamChangesTasksFingerprint,
|
||||
TEAM_CHANGES_MAX_REQUESTS,
|
||||
TEAM_CHANGES_UNKNOWN_SCAN_LIMIT,
|
||||
} from '../teamChangesRequestPlan';
|
||||
|
||||
|
|
@ -49,6 +50,41 @@ describe('buildTeamChangeRequestPlan', () => {
|
|||
expect(plan.eligibleTaskIds.has('known-changed')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips deleted and duplicate tasks before counting candidates', () => {
|
||||
const plan = buildTeamChangeRequestPlan(
|
||||
[
|
||||
task({ id: 'changed', status: 'completed', changePresence: 'has_changes' }),
|
||||
task({ id: 'changed', status: 'completed', changePresence: 'has_changes' }),
|
||||
task({ id: 'deleted', status: 'deleted', changePresence: 'has_changes' }),
|
||||
],
|
||||
0,
|
||||
false
|
||||
);
|
||||
|
||||
expect(plan.requests.map((request) => request.taskId)).toEqual(['changed']);
|
||||
expect(plan.eligibleCount).toBe(1);
|
||||
expect(plan.deferredCount).toBe(0);
|
||||
});
|
||||
|
||||
it('caps selected requests and reports deferred candidates', () => {
|
||||
const plan = buildTeamChangeRequestPlan(
|
||||
Array.from({ length: TEAM_CHANGES_MAX_REQUESTS + 5 }, (_, index) =>
|
||||
task({
|
||||
id: `changed-${index}`,
|
||||
status: 'completed',
|
||||
changePresence: 'has_changes',
|
||||
updatedAt: `2026-05-09T08:${String(index).padStart(2, '0')}:00.000Z`,
|
||||
})
|
||||
),
|
||||
0,
|
||||
false
|
||||
);
|
||||
|
||||
expect(plan.requests).toHaveLength(TEAM_CHANGES_MAX_REQUESTS);
|
||||
expect(plan.eligibleCount).toBe(TEAM_CHANGES_MAX_REQUESTS + 5);
|
||||
expect(plan.deferredCount).toBe(5);
|
||||
});
|
||||
|
||||
it('rotates unknown scans and preserves summary-only request options', () => {
|
||||
const tasks = Array.from({ length: TEAM_CHANGES_UNKNOWN_SCAN_LIMIT + 4 }, (_, index) =>
|
||||
task({
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import {
|
|||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
AlignLeft,
|
||||
AlertTriangle,
|
||||
ArrowLeftFromLine,
|
||||
ArrowRightFromLine,
|
||||
Check,
|
||||
|
|
@ -166,6 +167,7 @@ export const TaskDetailDialog = ({
|
|||
const [taskLogStreamCount, setTaskLogStreamCount] = useState<number | undefined>(undefined);
|
||||
const [changesSectionOpen, setChangesSectionOpen] = useState(false);
|
||||
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
||||
const [taskChangesWarnings, setTaskChangesWarnings] = useState<string[]>([]);
|
||||
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
||||
const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
|
||||
const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null);
|
||||
|
|
@ -235,6 +237,7 @@ export const TaskDetailDialog = ({
|
|||
useEffect(() => {
|
||||
setChangesSectionOpen(false);
|
||||
setTaskChangesFiles(null);
|
||||
setTaskChangesWarnings([]);
|
||||
setTaskChangesLoading(false);
|
||||
setTaskChangesError(null);
|
||||
setLogsRefreshing(false);
|
||||
|
|
@ -392,6 +395,7 @@ export const TaskDetailDialog = ({
|
|||
const syncTaskChangeSummaryResult = useCallback(
|
||||
(data: TaskChangeSetV2 | null) => {
|
||||
setTaskChangesFiles(data?.files ?? null);
|
||||
setTaskChangesWarnings(data?.warnings ?? []);
|
||||
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
||||
if (currentTask && taskChangeRequestOptions) {
|
||||
recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence);
|
||||
|
|
@ -441,6 +445,7 @@ export const TaskDetailDialog = ({
|
|||
}
|
||||
if (!preserveFilesOnError) {
|
||||
setTaskChangesFiles(null);
|
||||
setTaskChangesWarnings([]);
|
||||
}
|
||||
setTaskChangesError(
|
||||
error instanceof Error ? error.message : 'Failed to load task changes summary'
|
||||
|
|
@ -583,6 +588,14 @@ export const TaskDetailDialog = ({
|
|||
setChangesSectionOpen(isOpen);
|
||||
}, []);
|
||||
|
||||
const taskChangesBadge = !taskChangesLoading
|
||||
? taskChangesFiles && taskChangesFiles.length > 0
|
||||
? taskChangesFiles.length
|
||||
: taskChangesFiles && taskChangesWarnings.length > 0
|
||||
? 'attention'
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
const [taskDurationNowMs, setTaskDurationNowMs] = useState(() => Date.now());
|
||||
const taskImplementationDuration = useMemo(
|
||||
() => calculateTaskImplementationDuration(currentTask, taskDurationNowMs),
|
||||
|
|
@ -1186,9 +1199,7 @@ export const TaskDetailDialog = ({
|
|||
key={`task-changes:${currentTask.id}`}
|
||||
title="Changes"
|
||||
icon={<FileDiff size={14} />}
|
||||
badge={
|
||||
!taskChangesLoading && taskChangesFiles ? taskChangesFiles.length : undefined
|
||||
}
|
||||
badge={taskChangesBadge}
|
||||
headerExtra={
|
||||
taskChangesLoading && !changesSectionOpen ? (
|
||||
<Loader2
|
||||
|
|
@ -1231,76 +1242,105 @@ export const TaskDetailDialog = ({
|
|||
</div>
|
||||
) : taskChangesError ? (
|
||||
<p className="text-xs text-red-400">{taskChangesError}</p>
|
||||
) : taskChangesFiles && 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);
|
||||
}}
|
||||
) : taskChangesFiles ? (
|
||||
<div className="space-y-2">
|
||||
{taskChangesWarnings.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5">
|
||||
{taskChangesWarnings.slice(0, 2).map((warning) => (
|
||||
<div
|
||||
key={warning}
|
||||
className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]"
|
||||
>
|
||||
{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>
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
{taskChangesWarnings.length > 2 ? (
|
||||
<p className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{taskChangesWarnings.length - 2} more warnings
|
||||
</p>
|
||||
) : null}
|
||||
</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>
|
||||
) : changesSectionOpen ? (
|
||||
<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);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -224,7 +224,7 @@ describe('KanbanTaskCard change badge', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Changes"]')).toBeNull();
|
||||
expect(host.querySelector('[aria-label="Changes need attention"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -263,14 +263,22 @@ export const KanbanTaskCard = memo(
|
|||
|
||||
const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim();
|
||||
const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0;
|
||||
const canOpenChanges =
|
||||
canDisplay &&
|
||||
(task.changePresence === 'has_changes' || task.changePresence === 'needs_attention');
|
||||
const changesNeedAttention = task.changePresence === 'needs_attention';
|
||||
const metaActions = (
|
||||
<>
|
||||
{canDisplay && task.changePresence === 'has_changes' ? (
|
||||
{canOpenChanges ? (
|
||||
<TaskActionIconButton
|
||||
label="Changes"
|
||||
label={changesNeedAttention ? 'Changes need attention' : 'Changes'}
|
||||
icon={<FileCode className="size-2.5" />}
|
||||
variant="ghost"
|
||||
className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300"
|
||||
className={
|
||||
changesNeedAttention
|
||||
? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300'
|
||||
: 'text-sky-400 hover:bg-sky-500/10 hover:text-sky-300'
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewChanges!(task.id);
|
||||
|
|
|
|||
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 (statusMessageId !== normalizedMessageId) {
|
||||
const blockerUserVisibleState = status.userVisibleImpact?.state;
|
||||
const blockerStillChecking =
|
||||
status.userVisibleImpact?.state === 'checking' || status.responsePending === true;
|
||||
blockerUserVisibleState !== undefined
|
||||
? blockerUserVisibleState === 'checking'
|
||||
: status.responsePending === true;
|
||||
if (!blockerStillChecking) {
|
||||
const ownStatus = await unwrapIpc('team:getOpenCodeRuntimeDeliveryStatus', () =>
|
||||
api.teams.getOpenCodeRuntimeDeliveryStatus(teamName, normalizedMessageId)
|
||||
|
|
|
|||
|
|
@ -173,6 +173,9 @@ export function shouldClearPendingReplyForOpenCodeRuntimeDelivery(
|
|||
return false;
|
||||
}
|
||||
const userVisibleState = runtimeDelivery.userVisibleImpact?.state;
|
||||
if (userVisibleState === 'none') {
|
||||
return true;
|
||||
}
|
||||
if (userVisibleState === 'warning' || userVisibleState === 'error') {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -664,6 +664,12 @@ export interface InboxMessage {
|
|||
toolCalls?: ToolCallMeta[];
|
||||
/** Renderer-friendly semantic kind. Defaults to "default" when absent. */
|
||||
messageKind?: InboxMessageKind;
|
||||
/** Structured member-work-sync intent for runtime delivery and audit. */
|
||||
workSyncIntent?: 'agenda_sync' | 'review_pickup';
|
||||
/** Stable intent key, e.g. one review request event or a small review-request group. */
|
||||
workSyncIntentKey?: string;
|
||||
/** Concrete review_requested event IDs covered by this nudge. */
|
||||
workSyncReviewRequestEventIds?: string[];
|
||||
/** Structured slash-command metadata for sent command rows. */
|
||||
slashCommand?: SlashCommandMeta;
|
||||
/** Structured command-output metadata for session-derived result rows. */
|
||||
|
|
@ -708,6 +714,9 @@ export interface SendMessageRequest {
|
|||
toolSummary?: string;
|
||||
toolCalls?: ToolCallMeta[];
|
||||
messageKind?: InboxMessageKind;
|
||||
workSyncIntent?: InboxMessage['workSyncIntent'];
|
||||
workSyncIntentKey?: string;
|
||||
workSyncReviewRequestEventIds?: string[];
|
||||
slashCommand?: SlashCommandMeta;
|
||||
commandOutput?: CommandOutputMeta;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,7 +245,242 @@ describe('buildActionableWorkAgenda', () => {
|
|||
taskId: 'task-1',
|
||||
kind: 'review',
|
||||
assignee: 'alice',
|
||||
evidence: { reviewer: 'alice' },
|
||||
evidence: {
|
||||
reviewer: 'alice',
|
||||
reviewObligation: 'review_pickup_required',
|
||||
reviewRequestEventId: 'evt-1',
|
||||
canBypassPhase2: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('routes self-review to lead oversight instead of reviewer pickup', () => {
|
||||
const ownerAgenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'team-lead', agentType: 'lead' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-self-review',
|
||||
subject: 'Self review should be reassigned',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-self-review',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
const leadAgenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'team-lead',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'team-lead', agentType: 'lead' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-self-review',
|
||||
subject: 'Self review should be reassigned',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-self-review',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(ownerAgenda.items).toEqual([]);
|
||||
expect(leadAgenda.items).toHaveLength(1);
|
||||
expect(leadAgenda.items[0]).toMatchObject({
|
||||
taskId: 'task-self-review',
|
||||
kind: 'clarification',
|
||||
assignee: 'team-lead',
|
||||
reason: 'self_review_invalid',
|
||||
evidence: {
|
||||
owner: 'alice',
|
||||
reviewer: 'alice',
|
||||
reviewRequestEventId: 'evt-self-review',
|
||||
reviewDiagnostics: ['self_review_invalid'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat an older review_started event as progress for a newer review request', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
subject: 'Review current request',
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-old-start',
|
||||
type: 'review_started',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
actor: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'evt-old-approved',
|
||||
type: 'review_approved',
|
||||
timestamp: '2026-04-29T00:01:00.000Z',
|
||||
actor: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'evt-new-request',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:02:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items).toHaveLength(1);
|
||||
expect(agenda.items[0]?.evidence).toMatchObject({
|
||||
reviewObligation: 'review_pickup_required',
|
||||
reviewRequestEventId: 'evt-new-request',
|
||||
canBypassPhase2: true,
|
||||
historyEventIds: ['evt-new-request'],
|
||||
});
|
||||
expect(agenda.items[0]?.evidence.reviewStartedEventId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('routes a newer review request to the requested reviewer even when kanban reviewer is stale', () => {
|
||||
const aliceAgenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
kanbanReviewersByTaskId: { 'task-1': 'alice' },
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
subject: 'Reviewer changed',
|
||||
status: 'completed',
|
||||
owner: 'tom',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-old-request',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'evt-new-request',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:01:00.000Z',
|
||||
reviewer: 'bob',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
const bobAgenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
kanbanReviewersByTaskId: { 'task-1': 'alice' },
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
subject: 'Reviewer changed',
|
||||
status: 'completed',
|
||||
owner: 'tom',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-old-request',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'evt-new-request',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:01:00.000Z',
|
||||
reviewer: 'bob',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(aliceAgenda.items).toEqual([]);
|
||||
expect(bobAgenda.items).toHaveLength(1);
|
||||
expect(bobAgenda.items[0]?.evidence).toMatchObject({
|
||||
reviewer: 'bob',
|
||||
reviewObligation: 'review_pickup_required',
|
||||
reviewRequestEventId: 'evt-new-request',
|
||||
reviewDiagnostics: ['kanban_reviewer_differs_from_review_request'],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a started review as in-progress evidence that cannot bypass phase2', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
subject: 'Review already started',
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-request',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'evt-start',
|
||||
type: 'review_started',
|
||||
timestamp: '2026-04-29T00:01:00.000Z',
|
||||
actor: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items[0]?.evidence).toMatchObject({
|
||||
reviewObligation: 'review_in_progress',
|
||||
reviewRequestEventId: 'evt-request',
|
||||
reviewStartedEventId: 'evt-start',
|
||||
reviewStartedBy: 'alice',
|
||||
canBypassPhase2: false,
|
||||
historyEventIds: ['evt-request', 'evt-start'],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
type MemberWorkSyncAuditEvent,
|
||||
type MemberWorkSyncInboxNudgePort,
|
||||
type MemberWorkSyncOutboxStorePort,
|
||||
type MemberWorkSyncReviewPickupDeliveryPort,
|
||||
type MemberWorkSyncReviewPickupEscalationPort,
|
||||
type MemberWorkSyncStatusStorePort,
|
||||
type MemberWorkSyncUseCaseDeps,
|
||||
} from '@features/member-work-sync/core/application';
|
||||
|
|
@ -41,6 +43,40 @@ const workItem: MemberWorkSyncActionableWorkItem = {
|
|||
},
|
||||
};
|
||||
|
||||
const reviewPickupItem: MemberWorkSyncActionableWorkItem = {
|
||||
taskId: 'task-review',
|
||||
displayId: '22222222',
|
||||
subject: 'Review docs',
|
||||
kind: 'review',
|
||||
assignee: 'bob',
|
||||
priority: 'review_requested',
|
||||
reason: 'current_cycle_review_assigned',
|
||||
evidence: {
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
reviewer: 'bob',
|
||||
reviewState: 'review',
|
||||
reviewCycleId: 'evt-review-request',
|
||||
reviewRequestEventId: 'evt-review-request',
|
||||
reviewObligation: 'review_pickup_required',
|
||||
canBypassPhase2: true,
|
||||
historyEventIds: ['evt-review-request'],
|
||||
},
|
||||
};
|
||||
|
||||
const secondReviewPickupItem: MemberWorkSyncActionableWorkItem = {
|
||||
...reviewPickupItem,
|
||||
taskId: 'task-review-b',
|
||||
displayId: '33333333',
|
||||
subject: 'Review API',
|
||||
evidence: {
|
||||
...reviewPickupItem.evidence,
|
||||
reviewCycleId: 'evt-review-request-b',
|
||||
reviewRequestEventId: 'evt-review-request-b',
|
||||
historyEventIds: ['evt-review-request-b'],
|
||||
},
|
||||
};
|
||||
|
||||
class MutableClock {
|
||||
private current = new Date('2026-04-29T00:00:00.000Z');
|
||||
|
||||
|
|
@ -185,6 +221,8 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
|||
...current,
|
||||
status: 'delivered',
|
||||
deliveredMessageId: input.deliveredMessageId,
|
||||
...(input.deliveryState ? { deliveryState: input.deliveryState } : {}),
|
||||
...(input.deliveryDiagnostics ? { deliveryDiagnostics: input.deliveryDiagnostics } : {}),
|
||||
updatedAt: input.nowIso,
|
||||
});
|
||||
}
|
||||
|
|
@ -218,6 +256,26 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
|||
item.updatedAt >= input.sinceIso
|
||||
).length;
|
||||
}
|
||||
|
||||
async findDeliveredReviewPickupRequestEventIds(input: {
|
||||
memberName: string;
|
||||
reviewRequestEventIds: string[];
|
||||
}): Promise<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 {
|
||||
|
|
@ -242,6 +300,8 @@ function createDeps(options?: {
|
|||
outboxStore?: MemberWorkSyncOutboxStorePort;
|
||||
inboxNudge?: MemberWorkSyncInboxNudgePort;
|
||||
busySignal?: MemberWorkSyncUseCaseDeps['busySignal'];
|
||||
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
|
||||
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
|
||||
}) {
|
||||
const clock = new MutableClock();
|
||||
const store = new InMemoryStatusStore();
|
||||
|
|
@ -272,6 +332,12 @@ function createDeps(options?: {
|
|||
...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}),
|
||||
...(options?.inboxNudge ? { inboxNudge: options.inboxNudge } : {}),
|
||||
...(options?.busySignal ? { busySignal: options.busySignal } : {}),
|
||||
...(options?.reviewPickupDelivery
|
||||
? { reviewPickupDelivery: options.reviewPickupDelivery }
|
||||
: {}),
|
||||
...(options?.reviewPickupEscalation
|
||||
? { reviewPickupEscalation: options.reviewPickupEscalation }
|
||||
: {}),
|
||||
reportToken: {
|
||||
create: async (input) => ({
|
||||
token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`,
|
||||
|
|
@ -374,6 +440,26 @@ describe('MemberWorkSync use cases', () => {
|
|||
expect(result.status.report?.expiresAt).toBe('2026-04-29T00:02:00.000Z');
|
||||
});
|
||||
|
||||
it('uses a short still_working lease for review pickup reports', async () => {
|
||||
const { deps } = createDeps({ items: [reviewPickupItem] });
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
const reporter = new MemberWorkSyncReporter(deps);
|
||||
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
|
||||
const result = await reporter.execute({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: current.agenda.fingerprint,
|
||||
reportToken: current.reportToken,
|
||||
leaseTtlMs: 60 * 60 * 1000,
|
||||
source: 'test',
|
||||
});
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.status.report?.expiresAt).toBe('2026-04-29T00:10:00.000Z');
|
||||
});
|
||||
|
||||
it('rejects stale reports without turning app-side validation failures into pending intents', async () => {
|
||||
const { auditEvents, deps, store } = createDeps();
|
||||
const result = await new MemberWorkSyncReporter(deps).execute({
|
||||
|
|
@ -473,6 +559,178 @@ describe('MemberWorkSync use cases', () => {
|
|||
expect(outbox.ensures).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates review pickup outbox while shadow data is collecting only with delivery capability', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = {
|
||||
canDeliver: async () => ({ ok: true }),
|
||||
deliver: async () => ({
|
||||
ok: true,
|
||||
state: 'prompt_accepted',
|
||||
messageId: 'unused',
|
||||
}),
|
||||
};
|
||||
const { auditEvents, deps } = createDeps({
|
||||
items: [reviewPickupItem],
|
||||
providerId: 'opencode',
|
||||
outboxStore: outbox,
|
||||
reviewPickupDelivery,
|
||||
});
|
||||
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
|
||||
expect(outbox.ensures).toHaveLength(1);
|
||||
expect(outbox.ensures[0]).toMatchObject({
|
||||
id: 'member-work-sync:team-a:bob:review-pickup:evt-review-request',
|
||||
agendaFingerprint: status.agenda.fingerprint,
|
||||
payload: {
|
||||
workSyncIntent: 'review_pickup',
|
||||
workSyncIntentKey: 'review-pickup:evt-review-request',
|
||||
workSyncReviewRequestEventIds: ['evt-review-request'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates one review pickup outbox for multiple current review requests', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = {
|
||||
canDeliver: async () => ({ ok: true }),
|
||||
deliver: async () => ({
|
||||
ok: true,
|
||||
state: 'prompt_accepted',
|
||||
messageId: 'unused',
|
||||
}),
|
||||
};
|
||||
const { deps } = createDeps({
|
||||
items: [reviewPickupItem, secondReviewPickupItem],
|
||||
providerId: 'opencode',
|
||||
outboxStore: outbox,
|
||||
reviewPickupDelivery,
|
||||
});
|
||||
|
||||
await new MemberWorkSyncReconciler(deps).execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
|
||||
expect(outbox.ensures).toHaveLength(1);
|
||||
expect(outbox.ensures[0]).toMatchObject({
|
||||
id: 'member-work-sync:team-a:bob:review-pickup:evt-review-request+evt-review-request-b',
|
||||
payload: {
|
||||
workSyncIntent: 'review_pickup',
|
||||
workSyncIntentKey: 'review-pickup:evt-review-request+evt-review-request-b',
|
||||
workSyncReviewRequestEventIds: ['evt-review-request', 'evt-review-request-b'],
|
||||
taskRefs: [
|
||||
{ taskId: 'task-review', displayId: '22222222', teamName: 'team-a' },
|
||||
{ taskId: 'task-review-b', displayId: '33333333', teamName: 'team-a' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('filters already delivered review request ids before planning another pickup nudge', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = {
|
||||
canDeliver: async () => ({ ok: true }),
|
||||
deliver: async (input) => ({
|
||||
ok: true,
|
||||
state: 'prompt_accepted',
|
||||
messageId: input.messageId,
|
||||
}),
|
||||
};
|
||||
const { deps, source } = createDeps({
|
||||
items: [reviewPickupItem],
|
||||
providerId: 'opencode',
|
||||
outboxStore: outbox,
|
||||
inboxNudge: inbox,
|
||||
reviewPickupDelivery,
|
||||
});
|
||||
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||
|
||||
await reconciler.execute(
|
||||
{ teamName: 'team-a', memberName: 'bob' },
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
source.agenda.items = [reviewPickupItem, secondReviewPickupItem];
|
||||
await reconciler.execute(
|
||||
{ teamName: 'team-a', memberName: 'bob' },
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
|
||||
expect(outbox.ensures.at(-1)).toMatchObject({
|
||||
id: 'member-work-sync:team-a:bob:review-pickup:evt-review-request-b',
|
||||
payload: {
|
||||
workSyncIntent: 'review_pickup',
|
||||
workSyncReviewRequestEventIds: ['evt-review-request-b'],
|
||||
taskRefs: [{ taskId: 'task-review-b', displayId: '33333333', teamName: 'team-a' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create review pickup outbox when delivery capability is unavailable', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const escalations: Array<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 () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const { deps, store } = createDeps({ outboxStore: outbox });
|
||||
|
|
@ -559,6 +817,190 @@ describe('MemberWorkSync use cases', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('marks review pickup delivered only after the delivery port confirms prompt acceptance', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
const deliveryCalls: Array<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 () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import { decideMemberWorkSyncNudgeActivation } from '@features/member-work-sync/core/application';
|
||||
|
||||
import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '@features/member-work-sync/contracts';
|
||||
import type {
|
||||
MemberWorkSyncStatus,
|
||||
MemberWorkSyncTeamMetrics,
|
||||
} from '@features/member-work-sync/contracts';
|
||||
|
||||
function status(overrides: Partial<MemberWorkSyncStatus> = {}): MemberWorkSyncStatus {
|
||||
return {
|
||||
|
|
@ -102,6 +105,164 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
|
|||
).toEqual({ active: false, reason: 'phase2_not_ready' });
|
||||
});
|
||||
|
||||
it('allows strict review pickup nudges through phase2 collection before delivery capability is checked', () => {
|
||||
expect(
|
||||
decideMemberWorkSyncNudgeActivation({
|
||||
status: status({
|
||||
providerId: 'anthropic',
|
||||
agenda: {
|
||||
...status().agenda,
|
||||
items: [
|
||||
{
|
||||
taskId: 'task-review',
|
||||
displayId: '#2',
|
||||
subject: 'Review current request',
|
||||
kind: 'review',
|
||||
assignee: 'alice',
|
||||
priority: 'review_requested',
|
||||
reason: 'current_cycle_review_assigned',
|
||||
evidence: {
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewer: 'alice',
|
||||
reviewState: 'review',
|
||||
reviewCycleId: 'evt-review-request',
|
||||
reviewRequestEventId: 'evt-review-request',
|
||||
reviewObligation: 'review_pickup_required',
|
||||
canBypassPhase2: true,
|
||||
historyEventIds: ['evt-review-request'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
metrics: metrics(),
|
||||
})
|
||||
).toEqual({ active: true, reason: 'review_pickup_required' });
|
||||
});
|
||||
|
||||
it('does not bypass phase2 for review pickup when shadow would not nudge', () => {
|
||||
expect(
|
||||
decideMemberWorkSyncNudgeActivation({
|
||||
status: status({
|
||||
providerId: 'anthropic',
|
||||
shadow: {
|
||||
reconciledBy: 'queue',
|
||||
wouldNudge: false,
|
||||
fingerprintChanged: false,
|
||||
},
|
||||
agenda: {
|
||||
...status().agenda,
|
||||
items: [
|
||||
{
|
||||
taskId: 'task-review',
|
||||
displayId: '#2',
|
||||
subject: 'Review current request',
|
||||
kind: 'review',
|
||||
assignee: 'alice',
|
||||
priority: 'review_requested',
|
||||
reason: 'current_cycle_review_assigned',
|
||||
evidence: {
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewer: 'alice',
|
||||
reviewState: 'review',
|
||||
reviewCycleId: 'evt-review-request',
|
||||
reviewRequestEventId: 'evt-review-request',
|
||||
reviewObligation: 'review_pickup_required',
|
||||
canBypassPhase2: true,
|
||||
historyEventIds: ['evt-review-request'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
metrics: metrics(),
|
||||
})
|
||||
).toEqual({ active: false, reason: 'phase2_not_ready' });
|
||||
});
|
||||
|
||||
it('does not bypass phase2 for ambiguous review pickup evidence', () => {
|
||||
expect(
|
||||
decideMemberWorkSyncNudgeActivation({
|
||||
status: status({
|
||||
agenda: {
|
||||
...status().agenda,
|
||||
items: [
|
||||
{
|
||||
taskId: 'task-review',
|
||||
displayId: '#2',
|
||||
subject: 'Review current request',
|
||||
kind: 'review',
|
||||
assignee: 'alice',
|
||||
priority: 'review_requested',
|
||||
reason: 'current_cycle_review_assigned',
|
||||
evidence: {
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewer: 'alice',
|
||||
reviewState: 'review',
|
||||
reviewCycleId: 'kanban:alice',
|
||||
reviewObligation: 'review_pickup_required',
|
||||
canBypassPhase2: false,
|
||||
reviewDiagnostics: ['review_request_event_id_missing'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
metrics: metrics(),
|
||||
})
|
||||
).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' });
|
||||
});
|
||||
|
||||
it('allows multiple strict review pickup requests through the review pickup path', () => {
|
||||
const reviewItem = {
|
||||
taskId: 'task-review-a',
|
||||
displayId: '#2',
|
||||
subject: 'Review current request',
|
||||
kind: 'review' as const,
|
||||
assignee: 'alice',
|
||||
priority: 'review_requested' as const,
|
||||
reason: 'current_cycle_review_assigned',
|
||||
evidence: {
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewer: 'alice',
|
||||
reviewState: 'review',
|
||||
reviewCycleId: 'evt-review-request-a',
|
||||
reviewRequestEventId: 'evt-review-request-a',
|
||||
reviewObligation: 'review_pickup_required' as const,
|
||||
canBypassPhase2: true,
|
||||
historyEventIds: ['evt-review-request-a'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
decideMemberWorkSyncNudgeActivation({
|
||||
status: status({
|
||||
agenda: {
|
||||
...status().agenda,
|
||||
items: [
|
||||
reviewItem,
|
||||
{
|
||||
...reviewItem,
|
||||
taskId: 'task-review-b',
|
||||
evidence: {
|
||||
...reviewItem.evidence,
|
||||
reviewCycleId: 'evt-review-request-b',
|
||||
reviewRequestEventId: 'evt-review-request-b',
|
||||
historyEventIds: ['evt-review-request-b'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
metrics: metrics(),
|
||||
})
|
||||
).toEqual({ active: true, reason: 'review_pickup_required' });
|
||||
});
|
||||
|
||||
it('does not activate when blocking safety metrics are present', () => {
|
||||
expect(
|
||||
decideMemberWorkSyncNudgeActivation({
|
||||
|
|
|
|||
|
|
@ -46,13 +46,16 @@ function makeStatus(overrides: Partial<MemberWorkSyncStatus>): MemberWorkSyncSta
|
|||
};
|
||||
}
|
||||
|
||||
function makeNudgePayload(overrides: Partial<MemberWorkSyncNudgePayload> = {}): MemberWorkSyncNudgePayload {
|
||||
function makeNudgePayload(
|
||||
overrides: Partial<MemberWorkSyncNudgePayload> = {}
|
||||
): MemberWorkSyncNudgePayload {
|
||||
return {
|
||||
from: 'system',
|
||||
to: 'bob',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
source: 'member-work-sync',
|
||||
actionMode: 'do',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
text: 'Work sync check: continue the current task or report a blocker.',
|
||||
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
|
||||
...overrides,
|
||||
|
|
@ -129,7 +132,9 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
});
|
||||
|
||||
it('prefers member-scoped v2 status over legacy v1 status', async () => {
|
||||
await store.write(makeStatus({ state: 'caught_up', agenda: { ...makeStatus({}).agenda, items: [] } }));
|
||||
await store.write(
|
||||
makeStatus({ state: 'caught_up', agenda: { ...makeStatus({}).agenda, items: [] } })
|
||||
);
|
||||
|
||||
const legacyStatusPath = join(root, 'team-a', '.member-work-sync', 'status.json');
|
||||
await mkdir(join(root, 'team-a', '.member-work-sync'), { recursive: true });
|
||||
|
|
@ -252,9 +257,9 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)).toEqual([
|
||||
'tom',
|
||||
]);
|
||||
expect(
|
||||
Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)
|
||||
).toEqual(['tom']);
|
||||
});
|
||||
|
||||
it('repairs a partially missing pending-report index route from member-scoped report files', async () => {
|
||||
|
|
@ -491,7 +496,10 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
attemptGeneration: 2,
|
||||
});
|
||||
const index = JSON.parse(
|
||||
await readFile(join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), 'utf8')
|
||||
await readFile(
|
||||
join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(index.items[input.id]).toMatchObject({
|
||||
memberName: 'bob',
|
||||
|
|
@ -635,6 +643,107 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
expect(repaired.items[bobInput.id]).toMatchObject({ memberName: 'bob', status: 'delivered' });
|
||||
});
|
||||
|
||||
it('finds delivered review pickup request event ids from member-scoped outbox files', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:review-pickup:evt-a+evt-b',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:review',
|
||||
payloadHash: 'hash-review',
|
||||
payload: makeNudgePayload({
|
||||
workSyncIntent: 'review_pickup',
|
||||
workSyncIntentKey: 'review-pickup:evt-a+evt-b',
|
||||
workSyncReviewRequestEventIds: ['evt-a', 'evt-b'],
|
||||
}),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
await store.markDelivered({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
deliveredMessageId: 'message-1',
|
||||
deliveryState: 'prompt_accepted',
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.findDeliveredReviewPickupRequestEventIds({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
reviewRequestEventIds: ['evt-b', 'evt-c'],
|
||||
})
|
||||
).resolves.toEqual(['evt-b']);
|
||||
});
|
||||
|
||||
it('revives a claimed review pickup outbox item when only the payload text changed', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:review-pickup:evt-a',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:review-a',
|
||||
payloadHash: 'hash-review-a',
|
||||
payload: makeNudgePayload({
|
||||
workSyncIntent: 'review_pickup',
|
||||
workSyncIntentKey: 'review-pickup:evt-a',
|
||||
workSyncReviewRequestEventIds: ['evt-a'],
|
||||
text: 'Review pickup required: old subject',
|
||||
}),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
expect(claimed.status).toBe('claimed');
|
||||
|
||||
const result = await store.ensurePending({
|
||||
...input,
|
||||
agendaFingerprint: 'agenda:v1:review-b',
|
||||
payloadHash: 'hash-review-b',
|
||||
payload: {
|
||||
...input.payload,
|
||||
text: 'Review pickup required: renamed subject',
|
||||
},
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
outcome: 'existing',
|
||||
item: {
|
||||
status: 'pending',
|
||||
agendaFingerprint: 'agenda:v1:review-b',
|
||||
payloadHash: 'hash-review-b',
|
||||
payload: {
|
||||
workSyncIntent: 'review_pickup',
|
||||
workSyncIntentKey: 'review-pickup:evt-a',
|
||||
text: 'Review pickup required: renamed subject',
|
||||
},
|
||||
},
|
||||
});
|
||||
const [reclaimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-b',
|
||||
nowIso: '2026-04-29T00:03:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
expect(reclaimed).toMatchObject({
|
||||
id: input.id,
|
||||
payloadHash: 'hash-review-b',
|
||||
payload: { text: 'Review pickup required: renamed subject' },
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs stale due outbox index routes before persisting claim results', async () => {
|
||||
const bobInput = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
|
|
@ -667,11 +776,14 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
});
|
||||
expect(claimed.map((item) => item.memberName)).toEqual(['tom']);
|
||||
const repaired = JSON.parse(
|
||||
await readFile(join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), 'utf8')
|
||||
await readFile(
|
||||
join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)).toEqual([
|
||||
'tom',
|
||||
]);
|
||||
expect(
|
||||
Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)
|
||||
).toEqual(['tom']);
|
||||
});
|
||||
|
||||
it('repairs partially missing due outbox index routes before claiming', async () => {
|
||||
|
|
@ -755,8 +867,9 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
});
|
||||
expect(claimed).toHaveLength(1);
|
||||
expect(
|
||||
JSON.parse(await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8'))
|
||||
.items[input.id]
|
||||
JSON.parse(
|
||||
await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8')
|
||||
).items[input.id]
|
||||
).toMatchObject({ status: 'claimed' });
|
||||
expect(auditEvents.map((event) => `${event.event}:${event.reason}`)).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
|
|
|||
|
|
@ -215,4 +215,39 @@ describe('MemberWorkSyncTaskImpactResolver', () => {
|
|||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('targets lead oversight when the changed task is a self-review', async () => {
|
||||
const tasks: TeamTask[] = [
|
||||
{
|
||||
id: 'task-self-review',
|
||||
subject: 'Self review',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-self-review',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-05-06T19:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: { getTasks: vi.fn(async () => tasks) },
|
||||
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
|
||||
activeMemberSource: {
|
||||
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead']),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
resolver.resolve({ teamName: 'team-a', taskId: 'task-self-review' })
|
||||
).resolves.toEqual({
|
||||
memberNames: ['alice', 'team-lead'],
|
||||
fallbackTeamWide: false,
|
||||
diagnostics: ['self_review_invalid'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function makeInput(overrides: Partial<NudgeInput> = {}): NudgeInput {
|
|||
messageKind: 'member_work_sync_nudge',
|
||||
source: 'member-work-sync',
|
||||
actionMode: 'do',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
text: 'Please reconcile your current work state.',
|
||||
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
|
||||
},
|
||||
|
|
@ -73,6 +74,9 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
|
|||
summary: 'Work sync check',
|
||||
source: 'system_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
workSyncIntentKey: undefined,
|
||||
workSyncReviewRequestEventIds: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -299,7 +299,13 @@ async function forceRetryableOutboxDue(input: {
|
|||
expect(touched).toBeGreaterThan(0);
|
||||
await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
|
||||
await fs.promises.rm(
|
||||
path.join(input.teamsBasePath, input.teamName, '.member-work-sync', 'indexes', 'outbox-index.json'),
|
||||
path.join(
|
||||
input.teamsBasePath,
|
||||
input.teamName,
|
||||
'.member-work-sync',
|
||||
'indexes',
|
||||
'outbox-index.json'
|
||||
),
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
|
|
@ -794,7 +800,7 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
'utf8'
|
||||
);
|
||||
expect(journal).toContain('"event":"nudge_skipped"');
|
||||
expect(journal).toContain('"reason":"phase2_not_ready"');
|
||||
expect(journal).toContain('"reason":"blocking_metrics"');
|
||||
expect(journal).not.toContain('"event":"nudge_delivered"');
|
||||
} finally {
|
||||
await feature.dispose();
|
||||
|
|
@ -1098,7 +1104,7 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
'utf8'
|
||||
);
|
||||
expect(journal).toContain('"event":"nudge_skipped"');
|
||||
expect(journal).toContain('"reason":"phase2_not_ready"');
|
||||
expect(journal).toContain('"reason":"blocking_metrics"');
|
||||
});
|
||||
|
||||
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
||||
|
|
@ -1301,7 +1307,14 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
).toHaveLength(2);
|
||||
|
||||
const journal = await fs.promises.readFile(
|
||||
path.join(teamsBasePath, teamName, 'members', memberName, '.member-work-sync', 'journal.jsonl'),
|
||||
path.join(
|
||||
teamsBasePath,
|
||||
teamName,
|
||||
'members',
|
||||
memberName,
|
||||
'.member-work-sync',
|
||||
'journal.jsonl'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const events = journal
|
||||
|
|
@ -2422,10 +2435,7 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
try {
|
||||
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' });
|
||||
expect(env).toEqual({
|
||||
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(
|
||||
root,
|
||||
'.member-work-sync/runtime-hooks'
|
||||
),
|
||||
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
|
||||
});
|
||||
await expect(
|
||||
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
|
||||
|
|
@ -2448,10 +2458,7 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
try {
|
||||
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' });
|
||||
expect(env).toEqual({
|
||||
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(
|
||||
root,
|
||||
'.member-work-sync/runtime-hooks'
|
||||
),
|
||||
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
|
||||
});
|
||||
await expect(
|
||||
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
|
||||
|
|
@ -2470,10 +2477,7 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
});
|
||||
|
||||
expect(env).toEqual({
|
||||
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(
|
||||
root,
|
||||
'.member-work-sync/runtime-hooks'
|
||||
),
|
||||
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
|
||||
});
|
||||
await expect(
|
||||
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
|
||||
}
|
||||
|
|
@ -388,7 +462,9 @@ describe('ChangeExtractorService', () => {
|
|||
const { service } = createService({ logPaths: [] });
|
||||
const getTaskChanges = vi
|
||||
.spyOn(service, 'getTaskChanges')
|
||||
.mockImplementation(async (_teamName, taskId) => makeTaskChangeResult(taskId, { taskId }));
|
||||
.mockImplementation((_teamName, taskId) =>
|
||||
Promise.resolve(makeTaskChangeResult(taskId, { taskId }))
|
||||
);
|
||||
|
||||
const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
|
||||
{ taskId: 'task-1', options: SUMMARY_OPTIONS },
|
||||
|
|
@ -405,7 +481,9 @@ describe('ChangeExtractorService', () => {
|
|||
const { service } = createService({ logPaths: [] });
|
||||
const getTaskChanges = vi
|
||||
.spyOn(service, 'getTaskChanges')
|
||||
.mockImplementation(async (_teamName, taskId) => makeTaskChangeResult(taskId, { taskId }));
|
||||
.mockImplementation((_teamName, taskId) =>
|
||||
Promise.resolve(makeTaskChangeResult(taskId, { taskId }))
|
||||
);
|
||||
|
||||
const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
|
||||
null,
|
||||
|
|
@ -418,21 +496,48 @@ describe('ChangeExtractorService', () => {
|
|||
expect(getTaskChanges).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('limits raw team task summary request inspection before loading', async () => {
|
||||
const { service } = createService({ logPaths: [] });
|
||||
const getTaskChanges = vi
|
||||
.spyOn(service, 'getTaskChanges')
|
||||
.mockImplementation((_teamName, taskId) =>
|
||||
Promise.resolve(makeTaskChangeResult(taskId, { taskId }))
|
||||
);
|
||||
|
||||
const response = await service.getTeamTaskChangeSummaries(TEAM_NAME, [
|
||||
...Array.from({ length: 1000 }, () => null),
|
||||
{ taskId: 'beyond-inspect-limit', options: SUMMARY_OPTIONS },
|
||||
] as unknown as Parameters<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 () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
||||
const aliceLogPath = path.join(tmpDir, 'alice.jsonl');
|
||||
await writeJsonl(aliceLogPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const findLogFileRefsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) =>
|
||||
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
|
||||
const findLogFileRefsForTask = vi.fn(
|
||||
async (_teamName: string, _taskId: string, options?: any) =>
|
||||
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
|
||||
);
|
||||
const service = createService({ logPaths: [aliceLogPath], findLogFileRefsForTask }).service;
|
||||
|
||||
const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed' });
|
||||
const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
|
|
@ -449,7 +554,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-summary.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath] });
|
||||
|
|
@ -477,7 +587,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-restart.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const first = createService({ logPaths: [logPath] });
|
||||
|
|
@ -500,15 +615,30 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-refresh.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const { service } = createService({ logPaths: [logPath] });
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 2;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-2',
|
||||
'/repo/src/extra.ts',
|
||||
'export const extra = true;\n',
|
||||
'2026-03-01T10:02:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
|
|
@ -532,7 +662,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-review.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const { service } = createService({ logPaths: [logPath] });
|
||||
|
|
@ -565,7 +700,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-project-drift.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges(
|
||||
|
|
@ -574,11 +714,7 @@ describe('ChangeExtractorService', () => {
|
|||
SUMMARY_OPTIONS
|
||||
);
|
||||
const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' });
|
||||
await drifted.service.getTaskChanges(
|
||||
TEAM_NAME,
|
||||
TASK_ID,
|
||||
SUMMARY_OPTIONS
|
||||
);
|
||||
await drifted.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect((drifted.findLogFileRefsForTask as any).mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
|
@ -590,12 +726,25 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-missing-task.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(
|
||||
TEAM_NAME,
|
||||
TASK_ID,
|
||||
SUMMARY_OPTIONS
|
||||
);
|
||||
await fs.unlink(taskPath);
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(
|
||||
TEAM_NAME,
|
||||
TASK_ID,
|
||||
SUMMARY_OPTIONS
|
||||
);
|
||||
|
||||
await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
|
@ -607,10 +756,19 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-corrupt.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(
|
||||
TEAM_NAME,
|
||||
TASK_ID,
|
||||
SUMMARY_OPTIONS
|
||||
);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8');
|
||||
|
||||
|
|
@ -630,7 +788,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-fallback.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
|
|
@ -669,10 +832,20 @@ describe('ChangeExtractorService', () => {
|
|||
const firstLogPath = path.join(tmpDir, 'first.jsonl');
|
||||
const secondLogPath = path.join(tmpDir, 'second.jsonl');
|
||||
await writeJsonl(firstLogPath, [
|
||||
buildAssistantWriteEntry('tool-1', 'C:\\repo\\src\\same.ts', 'first\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'C:\\repo\\src\\same.ts',
|
||||
'first\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
await writeJsonl(secondLogPath, [
|
||||
buildAssistantWriteEntry('tool-2', 'C:/repo/src/same.ts', 'second\n', '2026-03-01T10:01:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-2',
|
||||
'C:/repo/src/same.ts',
|
||||
'second\n',
|
||||
'2026-03-01T10:01:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const service = createService({
|
||||
|
|
@ -722,7 +895,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn();
|
||||
|
|
@ -752,7 +930,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn(async () => {
|
||||
|
|
@ -783,7 +966,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn(async () => makeTaskChangeResult());
|
||||
|
|
@ -808,7 +996,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const firstWorker = {
|
||||
|
|
@ -1041,7 +1234,9 @@ describe('ChangeExtractorService', () => {
|
|||
}));
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
|
|
@ -1057,6 +1252,196 @@ describe('ChangeExtractorService', () => {
|
|||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs OpenCode recovery when a ledger result only contains warning notices', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeWarningOnlyLedgerNotice(projectDir, { memberName: 'bob' });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
||||
await writeOpenCodeLedgerEventJournal(input.projectDir, projectPath);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
projectDir: input.projectDir,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
dryRun: false,
|
||||
attributionMode: input.attributionMode,
|
||||
scannedSessions: 1,
|
||||
scannedToolparts: 1,
|
||||
candidateEvents: 1,
|
||||
importedEvents: 1,
|
||||
skippedEvents: 0,
|
||||
outcome: 'imported',
|
||||
notices: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
});
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
||||
undefined,
|
||||
workerClient as any,
|
||||
{ backfillOpenCodeTaskLedger } as any,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||
);
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.warnings).toContain(
|
||||
'Task change ledger skipped attribution because multiple task scopes were active.'
|
||||
);
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
||||
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recovers Codex warning-only ledger results through the scoped worker path', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { owner: 'tom' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeWarningOnlyLedgerNotice(projectDir, { memberName: 'tom' });
|
||||
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, {
|
||||
filePath: path.join(projectPath, 'src/codex.ts'),
|
||||
scope: { memberName: 'tom' },
|
||||
})
|
||||
),
|
||||
};
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath,
|
||||
members: [{ name: 'tom', providerId: 'codex' }],
|
||||
})),
|
||||
} as any,
|
||||
undefined,
|
||||
workerClient as any
|
||||
);
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
owner: 'tom',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.warnings).toContain(
|
||||
'Task change ledger skipped attribution because multiple task scopes were active.'
|
||||
);
|
||||
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps non-Codex warning-only ledger results as diagnostics instead of adding legacy changes', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { owner: 'atlas' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeWarningOnlyLedgerNotice(projectDir, { memberName: 'atlas' });
|
||||
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID)),
|
||||
};
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath,
|
||||
members: [{ name: 'atlas', providerId: 'anthropic' }],
|
||||
})),
|
||||
} as any,
|
||||
undefined,
|
||||
workerClient as any
|
||||
);
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
owner: 'atlas',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.warnings).toContain(
|
||||
'Task change ledger skipped attribution because multiple task scopes were active.'
|
||||
);
|
||||
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('backfills OpenCode ledger artifacts once before falling back to legacy extraction', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
@ -1697,7 +2082,9 @@ describe('ChangeExtractorService', () => {
|
|||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
||||
expect.stringContaining('delivery-context.json')
|
||||
);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(
|
||||
/^[a-f0-9]{64}$/
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cache negative OpenCode backfill while delivery context already exists', async () => {
|
||||
|
|
@ -1759,9 +2146,10 @@ describe('ChangeExtractorService', () => {
|
|||
skippedEvents: 0,
|
||||
outcome,
|
||||
notices: [],
|
||||
diagnostics: outcome === 'transient-error'
|
||||
? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.']
|
||||
: [],
|
||||
diagnostics:
|
||||
outcome === 'transient-error'
|
||||
? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.']
|
||||
: [],
|
||||
};
|
||||
});
|
||||
const workerClient = {
|
||||
|
|
@ -1809,11 +2197,15 @@ describe('ChangeExtractorService', () => {
|
|||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
||||
expect.stringContaining('delivery-context.json')
|
||||
);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(
|
||||
/^[a-f0-9]{64}$/
|
||||
);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
|
||||
expect.stringContaining('delivery-context.json')
|
||||
);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(
|
||||
/^[a-f0-9]{64}$/
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cache duplicates-only OpenCode backfill from an old evidence contract', async () => {
|
||||
|
|
@ -1902,8 +2294,7 @@ describe('ChangeExtractorService', () => {
|
|||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
opencodeTaskLedgerEvidenceContractVersion:
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
projectDir: input.projectDir,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,25 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => {
|
|||
expect(decision.controlText).not.toContain('reportToken=');
|
||||
});
|
||||
|
||||
it('uses review pickup repair wording for review pickup work-sync nudges', () => {
|
||||
const decision = decideOpenCodePromptDeliveryRepair(
|
||||
base({
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'review_pickup',
|
||||
actionMode: 'do',
|
||||
taskRefs: [{ taskId: 'task-1', displayId: '#1', teamName: 'team-a' }],
|
||||
responseState: 'responded_plain_text',
|
||||
pendingReason: 'plain_text_ack_only_still_requires_answer',
|
||||
})
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('work_sync_report_required');
|
||||
expect(decision.controlText).toContain('review pickup control message');
|
||||
expect(decision.controlText).toContain('start or continue the review');
|
||||
expect(decision.controlText).toContain('"task-1"');
|
||||
expect(decision.controlText).not.toContain('Then call agent-teams_member_work_sync_report');
|
||||
});
|
||||
|
||||
it('repairs visible replies that missed required taskRefs with exact metadata', () => {
|
||||
const taskRef = { taskId: 'task-refs-1', displayId: 'refs-1', teamName: 'team-a' };
|
||||
const decision = decideOpenCodePromptDeliveryRepair(
|
||||
|
|
|
|||
|
|
@ -593,6 +593,47 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
expect(sentText).not.toContain('You must not end this turn empty.');
|
||||
});
|
||||
|
||||
it('sends review pickup work sync nudges with review-oriented response instructions', async () => {
|
||||
const sendOpenCodeTeamMessage = vi.fn<
|
||||
NonNullable<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 () => {
|
||||
const sendOpenCodeTeamMessage = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
|
||||
|
|
|
|||
|
|
@ -8042,10 +8042,10 @@ describe('TeamProvisioningService', () => {
|
|||
}));
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'Work sync check for #task-1.',
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'Work sync check for #task-1.',
|
||||
messageId: 'msg-work-sync-report',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
|
|
@ -8069,6 +8069,58 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('accepts review workflow tools as review pickup delivery response proof', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<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 () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
|
|
|
|||
|
|
@ -431,7 +431,11 @@ describe('TaskDetailDialog changes summary loading', () => {
|
|||
'task-attention',
|
||||
expect.objectContaining({ summaryOnly: true })
|
||||
);
|
||||
expect(host.textContent).toContain('No file changes recorded');
|
||||
expect(host.textContent).toContain('No file changes were recorded for this task.');
|
||||
expect(host.textContent).toContain('No reviewable file changes recovered');
|
||||
expect(host.querySelector('[data-testid="section-badge-Changes"]')?.textContent).toBe(
|
||||
'attention'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.sendMessage.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildOpenCodeRuntimeDeliveryDiagnostics } from '../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
import {
|
||||
buildOpenCodeRuntimeDeliveryDiagnostics,
|
||||
shouldClearPendingReplyForOpenCodeRuntimeDelivery,
|
||||
} from '../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
|
||||
describe('openCodeRuntimeDeliveryDiagnostics', () => {
|
||||
it('honors user-visible checking impact over raw terminal delivery facts', () => {
|
||||
|
|
@ -58,6 +61,22 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
|
|||
expect(diagnostics).toEqual({ warning: null, debugDetails: null });
|
||||
});
|
||||
|
||||
it('clears pending reply when user-visible none impact overrides raw pending facts', () => {
|
||||
expect(
|
||||
shouldClearPendingReplyForOpenCodeRuntimeDelivery({
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'responded_non_visible_tool',
|
||||
ledgerStatus: 'responded',
|
||||
userVisibleImpact: {
|
||||
state: 'none',
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces terminal empty assistant turn in the compact failed warning', () => {
|
||||
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
|
||||
deliveredToInbox: true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue