feat(sync): expand member work review signals

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

View file

@ -75,11 +75,26 @@ function normalizeMessageKind(messageKind) {
return messageKind === 'default' ||
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 } : {}),

View file

@ -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');

View file

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

View file

@ -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;
}

View file

@ -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' };
}

View file

@ -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({

View file

@ -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

View file

@ -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;

View file

@ -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() }
: {}),
},
});

View file

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

View file

@ -29,18 +29,100 @@ export function buildMemberWorkSyncNudgeId(input: {
teamName: string;
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,

View file

@ -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',

View file

@ -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;
}

View file

@ -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();

View file

@ -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 {

View file

@ -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),

View file

@ -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),

View file

@ -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) =>

View file

@ -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;
}

View file

@ -57,6 +57,7 @@ const logger = createLogger('Service:ChangeExtractorService');
const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const;
const OPEN_CODE_AUTO_BACKFILL_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> {

View file

@ -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,

View file

@ -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' &&

View file

@ -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 }),
};

View file

@ -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,

View file

@ -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 ?? [],

View file

@ -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.`,
];
}

View file

@ -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>',

View file

@ -1,47 +1,30 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { 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';

View file

@ -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({

View file

@ -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>

View file

@ -195,7 +195,7 @@ describe('KanbanTaskCard change badge', () => {
});
});
it('does not render the Changes action when changePresence needs attention', async () => {
it('renders a Changes attention action when changePresence needs attention', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
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();

View file

@ -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);

View file

@ -0,0 +1,291 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
import {
buildTeamChangeRequestPlan,
buildTeamChangesTasksFingerprint,
} from './teamChangesRequestPlan';
import type { TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types';
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
export interface TeamChangeSummaryState {
taskId: string;
changeSet: TaskChangeSetV2 | null;
error?: string;
}
export interface TeamChangeStats {
eligibleCount: number;
requestedCount: number;
deferredCount: number;
}
interface TeamChangesLoadOptions {
forceFresh?: boolean;
showSpinner?: boolean;
preserveOnError?: boolean;
}
interface UseTeamChangesSummariesInput {
teamName: string;
tasks: TeamTaskWithKanban[];
sectionOpen: boolean;
}
interface UseTeamChangesSummariesResult {
summariesByTaskId: Record<string, TeamChangeSummaryState>;
stats: TeamChangeStats;
loading: boolean;
refreshing: boolean;
error: string | null;
refresh: () => void;
}
export function useTeamChangesSummaries({
teamName,
tasks,
sectionOpen,
}: UseTeamChangesSummariesInput): UseTeamChangesSummariesResult {
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
const [summariesByTaskId, setSummariesByTaskId] = useState<
Record<string, TeamChangeSummaryState>
>({});
const [stats, setStats] = useState<TeamChangeStats>({
eligibleCount: 0,
requestedCount: 0,
deferredCount: 0,
});
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queuedRefreshTick, setQueuedRefreshTick] = useState(0);
const hasLoadedRef = useRef(false);
const mountedRef = useRef(true);
const requestSeqRef = useRef(0);
const activeRequestSeqRef = useRef<number | null>(null);
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
const sectionOpenRef = useRef(sectionOpen);
const unknownScanCursorRef = useRef(0);
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
const tasksFingerprint = useMemo(
() => (sectionOpen ? buildTeamChangesTasksFingerprint(tasks) : ''),
[sectionOpen, tasks]
);
sectionOpenRef.current = sectionOpen;
useEffect(() => {
return () => {
mountedRef.current = false;
requestSeqRef.current += 1;
activeRequestSeqRef.current = null;
queuedRefreshOptionsRef.current = null;
};
}, []);
const loadSummaries = useCallback(
async ({
forceFresh = false,
showSpinner = false,
preserveOnError = true,
}: TeamChangesLoadOptions = {}): Promise<void> => {
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
const previous = queuedRefreshOptionsRef.current;
queuedRefreshOptionsRef.current = {
forceFresh: Boolean(previous?.forceFresh || forceFresh),
showSpinner: Boolean(previous?.showSpinner || showSpinner),
preserveOnError: previous
? Boolean(previous.preserveOnError && preserveOnError)
: preserveOnError,
};
requestSeqRef.current += 1;
if (activeRequestSeqRef.current === null && sectionOpenRef.current) {
setQueuedRefreshTick((value) => value + 1);
}
return;
}
const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh);
unknownScanCursorRef.current = plan.nextUnknownScanCursor;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
setStats({
eligibleCount: plan.eligibleCount,
requestedCount: plan.requestedCount,
deferredCount: plan.deferredCount,
});
setError(null);
if (plan.requests.length === 0) {
setSummariesByTaskId({});
setLoading(false);
setRefreshing(false);
return;
}
if (showSpinner) {
setLoading(true);
} else {
setRefreshing(true);
}
activeRequestSeqRef.current = requestSeq;
try {
const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests);
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return;
}
const currentTaskIds = new Set(tasks.map((task) => task.id));
for (const item of response.items) {
const changeSet = item.changeSet;
const options = plan.requestOptionsByTaskId.get(item.taskId);
if (!changeSet || !options) continue;
const nextPresence = resolveTaskChangePresenceFromResult(changeSet);
recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown');
}
setSummariesByTaskId((previous) => {
const next: Record<string, TeamChangeSummaryState> = {};
for (const [taskId, summary] of Object.entries(previous)) {
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
next[taskId] = summary;
}
}
for (const item of response.items) {
const options = plan.requestOptionsByTaskId.get(item.taskId);
if (!options) continue;
next[item.taskId] = {
taskId: item.taskId,
changeSet: item.changeSet,
error: item.error,
};
}
return next;
});
} catch (err) {
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return;
}
if (!preserveOnError) {
setSummariesByTaskId({});
}
setError(err instanceof Error ? err.message : 'Failed to load team changes');
} finally {
if (mountedRef.current) {
const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null;
if (activeRequestSeqRef.current === requestSeq) {
activeRequestSeqRef.current = null;
}
if (hasQueuedRefresh && activeRequestSeqRef.current === null && sectionOpenRef.current) {
setQueuedRefreshTick((value) => value + 1);
}
const shouldStopIndicators =
requestSeqRef.current === requestSeq ||
(!hasQueuedRefresh && activeRequestSeqRef.current === null);
if (shouldStopIndicators) {
setLoading(false);
setRefreshing(false);
}
}
}
},
[recordTaskChangePresence, setSelectedTeamTaskChangePresence, tasks, teamName]
);
useEffect(() => {
hasLoadedRef.current = false;
requestSeqRef.current += 1;
activeRequestSeqRef.current = null;
queuedRefreshOptionsRef.current = null;
unknownScanCursorRef.current = 0;
lastRequestedTasksFingerprintRef.current = null;
setSummariesByTaskId({});
setError(null);
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
}, [teamName]);
useEffect(() => {
if (!sectionOpen) {
requestSeqRef.current += 1;
activeRequestSeqRef.current = null;
queuedRefreshOptionsRef.current = null;
hasLoadedRef.current = false;
lastRequestedTasksFingerprintRef.current = null;
setSummariesByTaskId({});
setError(null);
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
setLoading(false);
setRefreshing(false);
}
}, [sectionOpen]);
useEffect(() => {
if (!sectionOpen || hasLoadedRef.current) {
return;
}
hasLoadedRef.current = true;
lastRequestedTasksFingerprintRef.current = tasksFingerprint;
void loadSummaries({ showSpinner: true, preserveOnError: false });
}, [loadSummaries, sectionOpen, tasksFingerprint]);
useEffect(() => {
if (!sectionOpen || !hasLoadedRef.current) {
return;
}
if (lastRequestedTasksFingerprintRef.current === tasksFingerprint) {
return;
}
lastRequestedTasksFingerprintRef.current = tasksFingerprint;
void loadSummaries({ showSpinner: false, preserveOnError: true });
}, [loadSummaries, sectionOpen, tasksFingerprint]);
useEffect(() => {
if (!sectionOpen || activeRequestSeqRef.current !== null) {
return;
}
const options = queuedRefreshOptionsRef.current;
if (!options) {
return;
}
queuedRefreshOptionsRef.current = null;
void loadSummaries(options);
}, [loadSummaries, queuedRefreshTick, sectionOpen]);
useEffect(() => {
if (!sectionOpen) {
return;
}
const timer = window.setInterval(() => {
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
return;
}
void loadSummaries({ showSpinner: false, preserveOnError: true });
}, TEAM_CHANGES_AUTO_REFRESH_MS);
return () => {
window.clearInterval(timer);
};
}, [loadSummaries, sectionOpen]);
const refresh = useCallback(() => {
void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false });
}, [loadSummaries]);
return {
summariesByTaskId,
stats,
loading,
refreshing,
error,
refresh,
};
}

View file

@ -4581,8 +4581,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
);
if (!status) return;
if (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)

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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'],
});
});

View file

@ -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();

View file

@ -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({

View file

@ -46,13 +46,16 @@ function makeStatus(overrides: Partial<MemberWorkSyncStatus>): MemberWorkSyncSta
};
}
function makeNudgePayload(overrides: Partial<MemberWorkSyncNudgePayload> = {}): MemberWorkSyncNudgePayload {
function makeNudgePayload(
overrides: Partial<MemberWorkSyncNudgePayload> = {}
): MemberWorkSyncNudgePayload {
return {
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([

View file

@ -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'],
});
});
});

View file

@ -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,
});
});

View file

@ -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'))

View file

@ -193,6 +193,80 @@ async function writeOpenCodeLedgerBundle(
);
}
async function writeWarningOnlyLedgerNotice(
projectDir: string,
overrides?: Partial<{
taskId: string;
memberName: string;
message: string;
}>
): Promise<void> {
const taskId = overrides?.taskId ?? TASK_ID;
const noticeDir = path.join(projectDir, '.board-task-changes', 'notices');
await fs.mkdir(noticeDir, { recursive: true });
await fs.writeFile(
path.join(noticeDir, `${encodeURIComponent(taskId)}.jsonl`),
`${JSON.stringify({
schemaVersion: 1,
noticeId: 'notice-1',
taskId,
taskRef: taskId,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 0,
sessionId: 'session-1',
memberName: overrides?.memberName ?? 'alice',
toolUseId: 'tool-1',
timestamp: '2026-03-01T10:05:00.000Z',
severity: 'warning',
code: 'multi-scope-skipped',
message:
overrides?.message ??
'Task change ledger skipped attribution because multiple task scopes were active.',
})}\n`,
'utf8'
);
}
async function writeOpenCodeLedgerEventJournal(
projectDir: string,
projectPath: string,
taskId: string = TASK_ID
): Promise<void> {
const eventDir = path.join(projectDir, '.board-task-changes', 'events');
await fs.mkdir(eventDir, { recursive: true });
await fs.writeFile(
path.join(eventDir, `${encodeURIComponent(taskId)}.jsonl`),
`${JSON.stringify({
schemaVersion: 1,
eventId: 'event-1',
taskId,
taskRef: taskId,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 0,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-1',
source: 'opencode_toolpart_write',
operation: 'create',
confidence: 'exact',
workspaceRoot: projectPath,
filePath: path.join(projectPath, 'src/opencode.ts'),
relativePath: 'src/opencode.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: 'export const source = "opencode";\n',
linesAdded: 1,
linesRemoved: 0,
})}\n`,
'utf8'
);
}
function persistedEntryPath(baseDir: string): string {
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,

View file

@ -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(

View file

@ -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']>

View file

@ -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>) => ({

View file

@ -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();

View file

@ -0,0 +1,425 @@
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
import { TeamMemberRuntimeAdvisoryService } from '@main/services/team/TeamMemberRuntimeAdvisoryService';
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS } from '@main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy';
import type { OpenCodePromptDeliveryLedgerRecord } from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import type {
MemberRuntimeAdvisory,
ResolvedTeamMember,
TeamChangeEvent,
} from '@shared/types';
const hoisted = vi.hoisted(() => ({
openExternal: vi.fn(),
}));
vi.mock('@renderer/api', () => ({
api: {
openExternal: hoisted.openExternal,
},
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) => React.createElement('span', { className, title }, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
CurrentTaskIndicator: () => null,
}));
import { MemberCard } from '@renderer/components/team/members/MemberCard';
const TEAM_NAME = 'opencode-advisory-e2e';
const MEMBER_NAME = 'jack';
const LANE_ID = 'secondary:opencode:jack';
const NOW_ISO = '2026-05-09T12:05:00.000Z';
const OLD_FAILURE_ISO = new Date(
Date.parse(NOW_ISO) - OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS - 5_000
).toISOString();
const FRESH_FAILURE_ISO = new Date(Date.parse(NOW_ISO) - 10_000).toISOString();
let tempDir = '';
let tempClaudeRoot = '';
interface SideEffectHarness {
addTeamNotification: ReturnType<typeof vi.fn>;
sendMessageToRun: ReturnType<typeof vi.fn>;
teamChangeEvents: TeamChangeEvent[];
invalidations: { teamName: string; memberName: string }[];
}
interface TeamProvisioningSideEffectAccess {
aliveRunByTeam: Map<string, string>;
runs: Map<string, unknown>;
sendMessageToRun: (run: unknown, text: string) => Promise<void>;
handleOpenCodeRuntimeDeliveryUserFacingSideEffects: (
record: OpenCodePromptDeliveryLedgerRecord
) => Promise<void>;
openCodeRuntimeDeliveryAdvisoryReviewTimers: Map<string, ReturnType<typeof setTimeout>>;
}
const baseMember: ResolvedTeamMember = {
name: MEMBER_NAME,
status: 'unknown',
taskCount: 0,
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
color: 'purple',
agentType: 'developer',
role: 'Developer',
providerId: 'opencode',
removedAt: undefined,
};
describe('MemberCard OpenCode delivery advisory fixture e2e', () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW_ISO));
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-card-opencode-advisory-e2e-'));
tempClaudeRoot = path.join(tempDir, '.claude');
await fs.mkdir(tempClaudeRoot, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot);
});
afterEach(async () => {
document.body.innerHTML = '';
hoisted.openExternal.mockReset();
NotificationManager.resetInstance();
setClaudeBasePathOverride(null);
vi.unstubAllGlobals();
vi.useRealTimers();
await fs.rm(tempDir, { recursive: true, force: true });
});
it('keeps a fresh generic terminal failure out of the member card and user-facing side effects', async () => {
const record = makeDeliveryRecord({
failedAt: FRESH_FAILURE_ISO,
updatedAt: FRESH_FAILURE_ISO,
lastObservedAt: FRESH_FAILURE_ISO,
respondedAt: FRESH_FAILURE_ISO,
});
await writeDeliveryFixture(record);
const advisory = await readMemberAdvisory();
expect(advisory).toBeNull();
const cardText = await renderMemberCardText(advisory);
expect(cardText).not.toContain('OpenCode delivery error');
expect(cardText).not.toContain('OpenCode returned an empty assistant turn');
const sideEffects = await runUserFacingSideEffects(record);
expect(sideEffects.addTeamNotification).not.toHaveBeenCalled();
expect(sideEffects.sendMessageToRun).not.toHaveBeenCalled();
expect(sideEffects.invalidations).toEqual([{ teamName: TEAM_NAME, memberName: MEMBER_NAME }]);
expect(sideEffects.teamChangeEvents).toContainEqual(
expect.objectContaining({
type: 'member-advisory',
teamName: TEAM_NAME,
})
);
});
it('suppresses a stale terminal failure across card, notification, and lead notice after visible reply proof appears', async () => {
const record = makeDeliveryRecord({
failedAt: OLD_FAILURE_ISO,
updatedAt: OLD_FAILURE_ISO,
lastObservedAt: OLD_FAILURE_ISO,
respondedAt: OLD_FAILURE_ISO,
});
await writeDeliveryFixture(record);
await writeVisibleRuntimeReplyProof(record);
const advisory = await readMemberAdvisory();
expect(advisory).toBeNull();
const cardText = await renderMemberCardText(advisory);
expect(cardText).not.toContain('OpenCode delivery error');
expect(cardText).not.toContain('OpenCode returned an empty assistant turn');
const sideEffects = await runUserFacingSideEffects(record);
expect(sideEffects.addTeamNotification).not.toHaveBeenCalled();
expect(sideEffects.sendMessageToRun).not.toHaveBeenCalled();
expect(sideEffects.invalidations).toEqual([{ teamName: TEAM_NAME, memberName: MEMBER_NAME }]);
});
it('still surfaces a stale terminal failure with no proof in the card, notification, and lead notice', async () => {
const record = makeDeliveryRecord({
failedAt: OLD_FAILURE_ISO,
updatedAt: OLD_FAILURE_ISO,
lastObservedAt: OLD_FAILURE_ISO,
respondedAt: OLD_FAILURE_ISO,
});
await writeDeliveryFixture(record);
const advisory = await readMemberAdvisory();
expect(advisory).toMatchObject({
kind: 'api_error',
reasonCode: 'backend_error',
message: 'OpenCode returned an empty assistant turn.',
});
const cardText = await renderMemberCardText(advisory);
expect(cardText).toContain('OpenCode delivery error');
expect(cardText).toContain('OpenCode returned an empty assistant turn.');
const sideEffects = await runUserFacingSideEffects(record);
expect(sideEffects.addTeamNotification).toHaveBeenCalledTimes(1);
expect(sideEffects.addTeamNotification.mock.calls[0]?.[0]).toMatchObject({
teamEventType: 'api_error',
teamName: TEAM_NAME,
from: MEMBER_NAME,
summary: 'OpenCode runtime error #task-1',
});
expect(sideEffects.sendMessageToRun).toHaveBeenCalledTimes(1);
expect(String(sideEffects.sendMessageToRun.mock.calls[0]?.[1])).toContain(
'System notice: OpenCode teammate @jack hit a runtime delivery error while handling #task-1.'
);
});
});
async function readMemberAdvisory(): Promise<MemberRuntimeAdvisory | null> {
const service = new TeamMemberRuntimeAdvisoryService({
findMemberLogs: vi.fn(() => Promise.resolve([])),
});
return await service.getMemberAdvisory(TEAM_NAME, MEMBER_NAME);
}
async function renderMemberCardText(
runtimeAdvisory: MemberRuntimeAdvisory | null
): Promise<string> {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...baseMember,
runtimeAdvisory: runtimeAdvisory ?? undefined,
},
memberColor: 'purple',
runtimeSummary: 'OpenCode - kimi-k2.6',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
})
);
await Promise.resolve();
});
const text = host.textContent ?? '';
await act(async () => {
root.unmount();
await Promise.resolve();
});
host.remove();
return text;
}
async function runUserFacingSideEffects(
record: OpenCodePromptDeliveryLedgerRecord
): Promise<SideEffectHarness> {
const addTeamNotification = vi.fn(() => Promise.resolve(undefined));
NotificationManager.setInstance({ addTeamNotification } as never);
const service = new TeamProvisioningService();
const access = service as unknown as TeamProvisioningSideEffectAccess;
const sendMessageToRun = vi.fn(() => Promise.resolve(undefined));
const teamChangeEvents: TeamChangeEvent[] = [];
const invalidations: { teamName: string; memberName: string }[] = [];
service.setTeamChangeEmitter((event) => {
teamChangeEvents.push(event);
});
service.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
invalidations.push({ teamName, memberName });
});
access.sendMessageToRun = sendMessageToRun;
access.aliveRunByTeam.set(TEAM_NAME, 'lead-run-1');
access.runs.set('lead-run-1', {
runId: 'lead-run-1',
teamName: TEAM_NAME,
processKilled: false,
cancelRequested: false,
});
await access.handleOpenCodeRuntimeDeliveryUserFacingSideEffects(record);
for (const timer of access.openCodeRuntimeDeliveryAdvisoryReviewTimers.values()) {
clearTimeout(timer);
}
access.openCodeRuntimeDeliveryAdvisoryReviewTimers.clear();
return {
addTeamNotification,
sendMessageToRun,
teamChangeEvents,
invalidations,
};
}
function makeDeliveryRecord(
overrides: Partial<OpenCodePromptDeliveryLedgerRecord> = {}
): OpenCodePromptDeliveryLedgerRecord {
return {
id: 'opencode-prompt:msg-empty-turn',
teamName: TEAM_NAME,
memberName: MEMBER_NAME,
laneId: LANE_ID,
runId: 'opencode-run-1',
runtimeSessionId: 'session-jack',
inboxMessageId: 'msg-empty-turn',
inboxTimestamp: overrides.inboxTimestamp ?? OLD_FAILURE_ISO,
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: 'ask',
taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName: TEAM_NAME }],
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'empty_assistant_turn',
attempts: 3,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: overrides.lastAttemptAt ?? OLD_FAILURE_ISO,
lastObservedAt: overrides.lastObservedAt ?? OLD_FAILURE_ISO,
acceptedAt: overrides.acceptedAt ?? OLD_FAILURE_ISO,
respondedAt: overrides.respondedAt ?? OLD_FAILURE_ISO,
failedAt: overrides.failedAt ?? OLD_FAILURE_ISO,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'opencode-user-msg-1',
observedAssistantMessageId: 'opencode-assistant-empty',
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
createdAt: overrides.createdAt ?? OLD_FAILURE_ISO,
updatedAt: overrides.updatedAt ?? OLD_FAILURE_ISO,
...overrides,
};
}
async function writeDeliveryFixture(record: OpenCodePromptDeliveryLedgerRecord): Promise<void> {
const teamDir = path.join(tempClaudeRoot, 'teams', TEAM_NAME);
const runtimeDir = path.join(teamDir, '.opencode-runtime');
const laneDir = path.join(runtimeDir, 'lanes', encodeURIComponent(LANE_ID));
await fs.mkdir(laneDir, { recursive: true });
await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
`${JSON.stringify(
{
name: TEAM_NAME,
projectPath: path.join(tempDir, 'project'),
leadSessionId: 'lead-session',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{ name: MEMBER_NAME, role: 'Developer', providerId: 'opencode' },
],
},
null,
2
)}\n`,
'utf8'
);
await fs.writeFile(
path.join(runtimeDir, 'lanes.json'),
`${JSON.stringify(
{
version: 1,
updatedAt: record.updatedAt,
lanes: {
[LANE_ID]: {
laneId: LANE_ID,
state: 'active',
updatedAt: record.updatedAt,
},
},
},
null,
2
)}\n`,
'utf8'
);
await fs.writeFile(
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
`${JSON.stringify(
{
schemaVersion: 1,
updatedAt: record.updatedAt,
data: [record],
},
null,
2
)}\n`,
'utf8'
);
}
async function writeVisibleRuntimeReplyProof(
record: OpenCodePromptDeliveryLedgerRecord
): Promise<void> {
await fs.writeFile(
path.join(tempClaudeRoot, 'teams', TEAM_NAME, 'inboxes', 'team-lead.json'),
`${JSON.stringify(
[
{
from: MEMBER_NAME,
to: 'team-lead',
text: 'Done, visible reply already delivered.',
timestamp: NOW_ISO,
read: false,
source: 'runtime_delivery',
messageId: 'visible-runtime-reply-1',
relayOfMessageId: record.inboxMessageId,
taskRefs: record.taskRefs,
},
],
null,
2
)}\n`,
'utf8'
);
}

View file

@ -542,6 +542,89 @@ describe('teamSlice actions', () => {
});
});
it('checks the original message when queued blocker impact is no longer user-visible', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({
deliveredToInbox: true,
messageId: 'm-opencode-queued',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: true,
responsePending: true,
responseState: 'pending',
ledgerStatus: 'accepted',
queuedBehindMessageId: 'm-opencode-blocker',
reason: 'opencode_delivery_response_pending',
diagnostics: ['opencode_delivery_response_pending'],
userVisibleImpact: {
state: 'checking',
},
},
});
hoisted.getOpenCodeRuntimeDeliveryStatus
.mockResolvedValueOnce({
messageId: 'm-opencode-blocker',
providerId: 'opencode',
attempted: true,
delivered: true,
responsePending: true,
responseState: 'responded_non_visible_tool',
ledgerStatus: 'responded',
acceptanceUnknown: false,
reason: 'non_visible_tool_without_task_progress',
diagnostics: ['non_visible_tool_without_task_progress'],
userVisibleImpact: {
state: 'none',
},
})
.mockResolvedValueOnce({
messageId: 'm-opencode-queued',
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'empty_assistant_turn',
ledgerStatus: 'failed_terminal',
acceptanceUnknown: false,
reason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
userVisibleImpact: {
state: 'error',
reasonCode: 'backend_error',
message: 'empty_assistant_turn',
},
});
await store.getState().sendTeamMessage('my-team', {
member: 'bob',
text: 'hello',
});
await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', {
messageId: 'm-opencode-queued',
statusMessageId: 'm-opencode-blocker',
});
expect(hoisted.getOpenCodeRuntimeDeliveryStatus).toHaveBeenNthCalledWith(
1,
'my-team',
'm-opencode-blocker'
);
expect(hoisted.getOpenCodeRuntimeDeliveryStatus).toHaveBeenNthCalledWith(
2,
'my-team',
'm-opencode-queued'
);
expect(store.getState().sendMessageWarning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
);
expect(store.getState().sendMessageDebugDetails).toMatchObject({
messageId: 'm-opencode-queued',
statusMessageId: 'm-opencode-queued',
userVisibleState: 'error',
});
});
it('clears OpenCode runtime diagnostics only for the matching message id', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({

View file

@ -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,