feat(member-work-sync): add nudge activation policy

This commit is contained in:
777genius 2026-05-06 19:19:43 +03:00
parent ac2b6c9352
commit 2e6549620f
9 changed files with 609 additions and 44 deletions

View file

@ -0,0 +1,58 @@
import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts';
export type MemberWorkSyncNudgeActivationReason =
| 'shadow_ready'
| 'opencode_targeted_shadow_collecting'
| 'status_not_nudgeable'
| 'blocking_metrics'
| 'phase2_not_ready';
export interface MemberWorkSyncNudgeActivationDecision {
active: boolean;
reason: MemberWorkSyncNudgeActivationReason;
}
const BLOCKING_PHASE2_REASONS = new Set([
'would_nudge_rate_high',
'fingerprint_churn_high',
'report_rejection_rate_high',
]);
function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean {
return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason));
}
function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean {
return (
status.providerId === 'opencode' &&
status.state === 'needs_sync' &&
status.agenda.items.length > 0 &&
status.shadow?.wouldNudge === true
);
}
export function decideMemberWorkSyncNudgeActivation(input: {
status: MemberWorkSyncStatus;
metrics: MemberWorkSyncTeamMetrics;
}): MemberWorkSyncNudgeActivationDecision {
if (input.status.state !== 'needs_sync' || input.status.agenda.items.length === 0) {
return { active: false, reason: 'status_not_nudgeable' };
}
if (hasBlockingMetrics(input.metrics)) {
return { active: false, reason: 'blocking_metrics' };
}
if (input.metrics.phase2Readiness.state === 'shadow_ready') {
return { active: true, reason: 'shadow_ready' };
}
if (
input.metrics.phase2Readiness.state === 'collecting_shadow_data' &&
isOpenCodeTargetedCandidate(input.status)
) {
return { active: true, reason: 'opencode_targeted_shadow_collecting' };
}
return { active: false, reason: 'phase2_not_ready' };
}

View file

@ -1,8 +1,9 @@
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
import { decideMemberWorkSyncStatus } from '../domain';
import type { MemberWorkSyncOutboxItem } from '../../contracts';
import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts';
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
@ -151,6 +152,12 @@ export class MemberWorkSyncNudgeDispatcher {
nowIso,
});
await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted');
await this.scheduleDeliveryWake(
item,
inserted.messageId,
inserted.inserted,
revalidation.providerId
);
return 'delivered';
} catch (error) {
await outbox.markFailed({
@ -188,7 +195,8 @@ export class MemberWorkSyncNudgeDispatcher {
item: MemberWorkSyncOutboxItem,
nowIso: string
): Promise<
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
| { ok: true; providerId?: MemberWorkSyncStatus['providerId'] }
| { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
> {
const teamActive = this.deps.lifecycle
? await this.deps.lifecycle.isTeamActive(item.teamName)
@ -221,6 +229,24 @@ export class MemberWorkSyncNudgeDispatcher {
nowIso,
inactive: source.inactive || !teamActive,
});
const providerId = source.providerId ?? previous.providerId;
const revalidatedStatus: MemberWorkSyncStatus = {
...previous,
state: decision.state,
agenda,
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
shadow: {
...previous.shadow,
reconciledBy: previous.shadow?.reconciledBy ?? 'queue',
wouldNudge: decision.state === 'needs_sync' && agenda.items.length > 0,
fingerprintChanged:
Boolean(previous.agenda.fingerprint) &&
previous.agenda.fingerprint !== agenda.fingerprint,
},
evaluatedAt: nowIso,
diagnostics: [...agenda.diagnostics, ...decision.diagnostics],
...(providerId ? { providerId } : {}),
};
if (
decision.state !== 'needs_sync' ||
agenda.items.length === 0 ||
@ -233,7 +259,11 @@ export class MemberWorkSyncNudgeDispatcher {
return { ok: false, reason: 'metrics_unavailable', retryable: true };
}
const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName);
if (metrics.phase2Readiness.state !== 'shadow_ready') {
const activation = decideMemberWorkSyncNudgeActivation({
status: revalidatedStatus,
metrics,
});
if (!activation.active) {
return { ok: false, reason: 'phase2_not_ready', retryable: true };
}
@ -281,6 +311,37 @@ export class MemberWorkSyncNudgeDispatcher {
return { ok: false, reason: 'watchdog_cooldown_active', retryable: true };
}
return { ok: true };
return { ok: true, ...(providerId ? { providerId } : {}) };
}
private async scheduleDeliveryWake(
item: MemberWorkSyncOutboxItem,
messageId: string,
inserted: boolean,
providerId?: MemberWorkSyncStatus['providerId']
): Promise<void> {
if (!this.deps.nudgeDeliveryWake) {
return;
}
try {
await this.deps.nudgeDeliveryWake.schedule({
teamName: item.teamName,
memberName: item.memberName,
messageId,
...(providerId ? { providerId } : {}),
reason: inserted ? 'member_work_sync_nudge_inserted' : 'member_work_sync_nudge_existing',
delayMs: 500,
});
} catch (error) {
const reason = `nudge_wake_failed:${String(error)}`;
await this.appendDispatchAudit(item, 'nudge_wake_failed', reason);
this.deps.logger?.warn('member work sync nudge delivery wake failed', {
teamName: item.teamName,
memberName: item.memberName,
messageId,
error: String(error),
});
}
}
}

View file

@ -1,6 +1,7 @@
import { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
import type { MemberWorkSyncStatus } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
@ -38,7 +39,8 @@ export class MemberWorkSyncNudgeOutboxPlanner {
}
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
if (metrics.phase2Readiness.state !== 'shadow_ready') {
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' };
}

View file

@ -1,6 +1,7 @@
export * from './MemberWorkSyncAudit';
export * from './MemberWorkSyncDiagnosticsReader';
export * from './MemberWorkSyncMetricsReader';
export * from './MemberWorkSyncNudgeActivationPolicy';
export * from './MemberWorkSyncNudgeDispatcher';
export * from './MemberWorkSyncNudgeOutboxPlanner';
export * from './MemberWorkSyncPendingReportIntentReplayer';

View file

@ -82,6 +82,7 @@ export type MemberWorkSyncAuditEventName =
| 'report_rejected'
| 'nudge_planned'
| 'nudge_delivered'
| 'nudge_wake_failed'
| 'nudge_skipped'
| 'nudge_retryable'
| 'nudge_superseded'
@ -181,6 +182,17 @@ export interface MemberWorkSyncBusySignalPort {
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
}
export interface MemberWorkSyncNudgeDeliveryWakePort {
schedule(input: {
teamName: string;
memberName: string;
messageId: string;
providerId?: MemberWorkSyncProviderId | null;
reason: 'member_work_sync_nudge_inserted' | 'member_work_sync_nudge_existing';
delayMs?: number;
}): Promise<void> | void;
}
export interface MemberWorkSyncUseCaseDeps {
clock: MemberWorkSyncClockPort;
hash: MemberWorkSyncHashPort;
@ -191,6 +203,7 @@ export interface MemberWorkSyncUseCaseDeps {
inboxNudge?: MemberWorkSyncInboxNudgePort;
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
busySignal?: MemberWorkSyncBusySignalPort;
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
reportToken?: MemberWorkSyncReportTokenPort;
auditJournal?: MemberWorkSyncAuditJournalPort;
lifecycle?: MemberWorkSyncLifecyclePort;

View file

@ -20,6 +20,7 @@ import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown';
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
import { CompositeMemberWorkSyncBusySignal } from '../infrastructure/CompositeMemberWorkSyncBusySignal';
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
@ -46,7 +47,11 @@ import type {
MemberWorkSyncStatusRequest,
MemberWorkSyncTeamMetrics,
} from '../../contracts';
import type { MemberWorkSyncLoggerPort } from '../../core/application';
import type {
MemberWorkSyncBusySignalPort,
MemberWorkSyncLoggerPort,
MemberWorkSyncNudgeDeliveryWakePort,
} from '../../core/application';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
@ -93,6 +98,8 @@ export function createMemberWorkSyncFeature(deps: {
listLifecycleActiveTeamNames?: () => Promise<string[]>;
queueQuietWindowMs?: number;
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
extraBusySignals?: MemberWorkSyncBusySignalPort[];
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
logger?: MemberWorkSyncLoggerPort;
}): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter();
@ -138,7 +145,12 @@ export function createMemberWorkSyncFeature(deps: {
});
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
const busySignal = new MemberWorkSyncToolActivityBusySignal();
const toolActivityBusySignal = new MemberWorkSyncToolActivityBusySignal();
const busySignals = [toolActivityBusySignal, ...(deps.extraBusySignals ?? [])];
const busySignal =
busySignals.length === 1
? toolActivityBusySignal
: new CompositeMemberWorkSyncBusySignal(busySignals, deps.logger);
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
const useCaseDeps = {
clock,
@ -150,6 +162,7 @@ export function createMemberWorkSyncFeature(deps: {
inboxNudge,
watchdogCooldown,
busySignal,
...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}),
reportToken,
auditJournal,
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
@ -233,7 +246,7 @@ export function createMemberWorkSyncFeature(deps: {
getMetrics: (request) => metricsReader.execute(request),
report: (request) => reporter.execute(request),
noteTeamChange: (event) => {
busySignal.noteTeamChange(event);
toolActivityBusySignal.noteTeamChange(event);
router.noteTeamChange(event);
},
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),

View file

@ -0,0 +1,35 @@
import type {
MemberWorkSyncBusySignalPort,
MemberWorkSyncLoggerPort,
} from '../../core/application';
export class CompositeMemberWorkSyncBusySignal implements MemberWorkSyncBusySignalPort {
constructor(
private readonly signals: MemberWorkSyncBusySignalPort[],
private readonly logger?: MemberWorkSyncLoggerPort
) {}
async isBusy(input: Parameters<MemberWorkSyncBusySignalPort['isBusy']>[0]) {
for (const signal of this.signals) {
try {
const result = await signal.isBusy(input);
if (result.busy) {
return result;
}
} catch (error) {
this.logger?.warn('member work sync busy signal failed', {
teamName: input.teamName,
memberName: input.memberName,
error: String(error),
});
return {
busy: true,
reason: 'busy_signal_error',
retryAfterIso: new Date(Date.parse(input.nowIso) + 60_000).toISOString(),
};
}
}
return { busy: false };
}
}

View file

@ -163,6 +163,10 @@ import {
type OpenCodePromptDeliveryLedgerStore,
type OpenCodePromptDeliveryStatus,
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
import {
decideOpenCodePromptDeliveryRepair,
type OpenCodePromptDeliveryHardFailureKind,
} from './opencode/delivery/OpenCodePromptDeliveryRepairPolicy';
import {
isOpenCodePromptDeliveryObserveLaterResponseState,
isOpenCodePromptDeliveryRetryableResponseState,
@ -5192,6 +5196,18 @@ function normalizeSameTeamText(text: string): string {
return text.trim().replace(/\r\n/g, '\n');
}
function getOpenCodeInboxRelayPriority(
message: Pick<InboxMessage, 'messageKind' | 'source'>
): number {
if (message.messageKind === 'member_work_sync_nudge') {
return 30;
}
if (message.source === 'system_notification') {
return 20;
}
return 0;
}
export class TeamProvisioningService {
private readonly runtimeLaneCoordinator = createTeamRuntimeLaneCoordinator();
private readonly providerConnectionService = ProviderConnectionService.getInstance();
@ -6713,6 +6729,9 @@ export class TeamProvisioningService {
if (state === 'prompt_delivered_no_assistant_message') {
return 'prompt_delivered_no_assistant_message';
}
if (state === 'tool_error') {
return 'tool_error_without_required_delivery_proof';
}
return record?.lastReason ?? 'opencode_delivery_response_pending';
}
@ -6775,40 +6794,62 @@ export class TeamProvisioningService {
return false;
}
private buildOpenCodePromptDeliveryAttemptText(input: {
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null;
text: string;
replyRecipient: string;
}): string {
const record = input.ledgerRecord;
if (!record || record.status === 'pending' || record.attempts <= 0) {
return input.text;
private getOpenCodeDeliveryHardFailureKind(
record?: OpenCodePromptDeliveryLedgerRecord | null
): OpenCodePromptDeliveryHardFailureKind {
if (!record) {
return 'none';
}
const visibleAnswerRequired =
record.lastReason === 'visible_reply_still_required' ||
record.lastReason === 'plain_text_ack_only_still_requires_answer' ||
(record.responseState === 'responded_non_visible_tool' &&
record.actionMode === 'ask' &&
record.taskRefs.length === 0);
const attemptNumber = Math.min(record.attempts + 1, record.maxAttempts);
const header = visibleAnswerRequired
? [
'<opencode_delivery_retry>',
`This is retry attempt ${attemptNumber}/${record.maxAttempts} for inbound app messageId "${record.inboxMessageId}".`,
`You accepted the earlier prompt but did not provide a visible/concrete answer for the recipient "${input.replyRecipient}".`,
`Please reply with agent-teams_message_send to "${input.replyRecipient}" and include relayOfMessageId="${record.inboxMessageId}". If that tool is unavailable, provide a concise plain-text answer.`,
'Do not repeat tool work unless needed and do not reply only with acknowledgement.',
'</opencode_delivery_retry>',
]
: [
'<opencode_delivery_retry>',
`This is retry attempt ${attemptNumber}/${record.maxAttempts} for inbound app messageId "${record.inboxMessageId}".`,
'The previous OpenCode turn was accepted, but the app still has no sufficient response proof for this message.',
`If you already acted on this message, do not duplicate work; send a concrete status via agent-teams_message_send with relayOfMessageId="${record.inboxMessageId}" or update the related task.`,
'Do not reply only with acknowledgement.',
'</opencode_delivery_retry>',
];
return `${header.join('\n')}\n\n${input.text}`;
if (record.status === 'failed_terminal') {
return 'unknown';
}
if (record.responseState === 'permission_blocked') {
return 'permission';
}
if (record.responseState === 'session_error') {
return 'session';
}
return 'none';
}
private buildOpenCodePromptDeliveryRepairControlText(input: {
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null;
readAllowed: boolean;
pendingReason: string;
}): string | null {
const record = input.ledgerRecord;
if (!record) {
return null;
}
return decideOpenCodePromptDeliveryRepair({
teamName: record.teamName,
memberName: record.memberName,
inboxMessageId: record.inboxMessageId,
replyRecipient: record.replyRecipient,
messageKind: record.messageKind,
actionMode: record.actionMode,
taskRefs: record.taskRefs,
status: record.status,
responseState: record.responseState,
attempts: record.attempts,
maxAttempts: record.maxAttempts,
pendingReason: input.pendingReason,
readAllowed: input.readAllowed,
inboxReadCommitted: Boolean(record.inboxReadCommittedAt),
visibleReplyFound: Boolean(record.visibleReplyMessageId),
hasKnownProgressProof: this.hasOpenCodeNonVisibleProgressProof(record),
toolCallNames: record.observedToolCallNames,
acceptanceUnknown: record.acceptanceUnknown,
hardFailureKind: this.getOpenCodeDeliveryHardFailureKind(record),
}).controlText;
}
private buildOpenCodePromptDeliveryAttemptText(input: {
text: string;
controlText?: string | null;
}): string {
const controlText = input.controlText?.trim();
return controlText ? `${controlText}\n\n${input.text}` : input.text;
}
private isOpenCodePromptAcceptanceUnknownFailure(diagnostics: readonly string[]): boolean {
@ -8182,10 +8223,31 @@ export class TeamProvisioningService {
}
}
const retryReadAllowed = ledgerRecord
? this.isOpenCodeDeliveryResponseReadCommitAllowed({
responseState: ledgerRecord.responseState,
actionMode: ledgerRecord.actionMode ?? undefined,
taskRefs: ledgerRecord.taskRefs,
visibleReply: null,
ledgerRecord,
})
: false;
const retryPendingReason = ledgerRecord
? this.getOpenCodeDeliveryPendingReason({
responseState: ledgerRecord.responseState,
actionMode: ledgerRecord.actionMode,
taskRefs: ledgerRecord.taskRefs,
visibleReply: null,
ledgerRecord,
})
: 'opencode_delivery_response_pending';
const deliveryText = this.buildOpenCodePromptDeliveryAttemptText({
ledgerRecord,
text: input.text,
replyRecipient: input.replyRecipient ?? ledgerRecord?.replyRecipient ?? 'user',
controlText: this.buildOpenCodePromptDeliveryRepairControlText({
ledgerRecord,
readAllowed: retryReadAllowed,
pendingReason: retryPendingReason,
}),
});
const result = await adapter.sendMessageToMember({
...(runtimeRunId ? { runId: runtimeRunId } : {}),
@ -18663,7 +18725,13 @@ export class TeamProvisioningService {
if (typeof message.text !== 'string' || message.text.trim().length === 0) return false;
return this.hasStableMessageId(message);
})
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
.sort((a, b) => {
const priorityDelta = getOpenCodeInboxRelayPriority(a) - getOpenCodeInboxRelayPriority(b);
if (priorityDelta !== 0) return priorityDelta;
const timeDelta = Date.parse(a.timestamp) - Date.parse(b.timestamp);
if (timeDelta !== 0) return timeDelta;
return a.messageId.localeCompare(b.messageId);
})
.slice(0, 10);
for (const message of unread) {

View file

@ -0,0 +1,314 @@
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
import type {
OpenCodePromptDeliveryLedgerRecord,
OpenCodePromptDeliveryStatus,
} from './OpenCodePromptDeliveryLedger';
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
export type OpenCodePromptDeliveryRepairKind =
| 'none'
| 'no_assistant_response'
| 'visible_answer_required'
| 'missing_visible_reply_correlation'
| 'work_sync_report_required'
| 'progress_proof_required'
| 'app_materialization_pending';
export type OpenCodePromptDeliveryHardFailureKind = 'none' | 'session' | 'permission' | 'unknown';
export interface OpenCodePromptDeliveryRepairDecision {
kind: OpenCodePromptDeliveryRepairKind;
retryable: boolean;
controlText: string | null;
reason: string;
}
export interface OpenCodePromptDeliveryRepairInput {
teamName: string;
memberName: string;
inboxMessageId: string;
replyRecipient: string;
messageKind: InboxMessageKind | null;
actionMode: AgentActionMode | null;
taskRefs: TaskRef[];
status: OpenCodePromptDeliveryStatus;
responseState: OpenCodeDeliveryResponseState;
attempts: number;
maxAttempts: number;
pendingReason: string;
readAllowed: boolean;
inboxReadCommitted: boolean;
visibleReplyFound: boolean;
hasKnownProgressProof: boolean;
toolCallNames: string[];
acceptanceUnknown: boolean;
hardFailureKind: OpenCodePromptDeliveryHardFailureKind;
}
const SIDE_EFFECT_TOOL_NAMES = new Set([
'bash',
'edit',
'write',
'patch',
'apply_patch',
'multiedit',
'multi_edit',
]);
function none(reason: string): OpenCodePromptDeliveryRepairDecision {
return { kind: 'none', retryable: false, controlText: null, reason };
}
function control(
input: OpenCodePromptDeliveryRepairInput,
kind: Exclude<OpenCodePromptDeliveryRepairKind, 'none'>,
reason: string,
lines: string[]
): OpenCodePromptDeliveryRepairDecision {
const attemptNumber = Math.min(Math.max(input.attempts + 1, 1), input.maxAttempts);
return {
kind,
retryable: true,
reason,
controlText: [
'<opencode_delivery_retry>',
`Retry attempt ${attemptNumber}/${input.maxAttempts} for inbound app messageId "${input.inboxMessageId}".`,
...lines,
'</opencode_delivery_retry>',
].join('\n'),
};
}
function normalizeToolName(toolName: string): string {
return toolName
.trim()
.toLowerCase()
.replace(/^mcp__agent[-_]teams__/, '')
.replace(/^agent[-_]teams_/, '')
.replace(/^mcp__agent_teams__/, '')
.replace(/^agent_teams_/, '');
}
function normalizedToolNames(input: OpenCodePromptDeliveryRepairInput): Set<string> {
return new Set(input.toolCallNames.map(normalizeToolName).filter(Boolean));
}
function hasTool(tools: Set<string>, toolName: string): boolean {
return tools.has(toolName);
}
function hasTaskTool(tools: Set<string>): boolean {
for (const tool of tools) {
if (tool.startsWith('task_') || tool === 'runtime_task_event') {
return true;
}
}
return false;
}
function hasSideEffectTool(tools: Set<string>): boolean {
for (const tool of tools) {
if (SIDE_EFFECT_TOOL_NAMES.has(tool)) {
return true;
}
}
return false;
}
function taskIdList(taskRefs: TaskRef[]): string | null {
const ids = [...new Set(taskRefs.map((taskRef) => taskRef.taskId?.trim()).filter(Boolean))];
return ids.length > 0 ? ids.map((id) => `"${id}"`).join(', ') : null;
}
function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
const replyRecipient = input.replyRecipient.trim() || 'user';
return [
'The app still has no correlated visible reply proof for this message.',
`Call agent-teams_message_send or mcp__agent-teams__message_send exactly once with teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", and relayOfMessageId="${input.inboxMessageId}".`,
'Use a concrete answer in text and summary. Do not reply only with acknowledgement.',
'After the message_send tool succeeds, stop this turn. Do not repeat task/tool work unless the inbound message explicitly asks for new work.',
];
}
function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
const taskIds = taskIdList(input.taskRefs);
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}".`,
'Then call agent-teams_member_work_sync_report or mcp__agent-teams__member_work_sync_report using the agendaFingerprint/reportToken returned by status.',
taskIds ? `Include taskIds ${taskIds} when reporting if those tasks are still relevant.` : null,
'Use state "still_working", "blocked", or "caught_up" according to the status result. Do not invent or reuse a raw report token from this retry text.',
].filter((line): line is string => line !== null);
}
function progressControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
const taskIds = taskIdList(input.taskRefs);
return [
'The app saw a tool/action response, but no accepted progress proof for this message.',
taskIds
? `Produce concrete task/progress proof for taskIds ${taskIds}, or send a visible status reply with relayOfMessageId="${input.inboxMessageId}".`
: `Send a concrete visible status reply with relayOfMessageId="${input.inboxMessageId}".`,
'Do not repeat side-effectful commands, edits, or writes just because this is a retry.',
'If work is blocked, report the blocker instead of silently ending the turn.',
];
}
function noAssistantControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
return [
'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.'
: `Send a concrete reply using message_send with relayOfMessageId="${input.inboxMessageId}", or provide a concrete plain-text answer only if message_send is unavailable.`,
];
}
function toolErrorControl(input: OpenCodePromptDeliveryRepairInput) {
const tools = normalizedToolNames(input);
if (hasTool(tools, 'message_send')) {
return control(
input,
'missing_visible_reply_correlation',
'message_send_tool_error_without_visible_reply_proof',
messageSendControlLines(input)
);
}
if (hasTool(tools, 'member_work_sync_report') || hasTool(tools, 'member_work_sync_status')) {
return control(
input,
'work_sync_report_required',
'member_work_sync_tool_error_without_report_proof',
workSyncControlLines(input)
);
}
if (hasSideEffectTool(tools)) {
return control(
input,
'progress_proof_required',
'side_effect_tool_error_without_progress_proof',
progressControlLines(input)
);
}
if (hasTaskTool(tools)) {
return control(
input,
'progress_proof_required',
'task_tool_error_without_progress_proof',
progressControlLines(input)
);
}
return control(
input,
'progress_proof_required',
'tool_error_without_required_delivery_proof',
progressControlLines(input)
);
}
export function decideOpenCodePromptDeliveryRepair(
input: OpenCodePromptDeliveryRepairInput
): OpenCodePromptDeliveryRepairDecision {
if (input.readAllowed) {
return none('read_commit_allowed');
}
if (input.inboxReadCommitted) {
return none('inbox_read_already_committed');
}
if (input.status === 'failed_terminal') {
return none('terminal_record');
}
if (input.attempts >= input.maxAttempts) {
return none('max_attempts_reached');
}
if (input.hardFailureKind !== 'none') {
return none(`hard_failure:${input.hardFailureKind}`);
}
if (input.status === 'pending' && input.attempts <= 0 && !input.acceptanceUnknown) {
return none('initial_delivery');
}
if (input.acceptanceUnknown) {
return control(input, 'no_assistant_response', 'acceptance_unknown', [
'The app could not confirm whether the previous OpenCode prompt was accepted.',
'Process the inbound message now. If you already completed it, send only the missing proof and do not duplicate side effects.',
input.messageKind === 'member_work_sync_nudge'
? 'For work-sync, use member_work_sync_status then member_work_sync_report.'
: `For visible replies, use relayOfMessageId="${input.inboxMessageId}".`,
]);
}
if (input.messageKind === 'member_work_sync_nudge') {
return control(
input,
'work_sync_report_required',
input.pendingReason,
workSyncControlLines(input)
);
}
if (input.pendingReason === 'plain_text_visible_reply_not_materialized_yet') {
return {
kind: 'app_materialization_pending',
retryable: false,
controlText: null,
reason: input.pendingReason,
};
}
if (
input.pendingReason === 'visible_reply_destination_not_found_yet' ||
input.pendingReason === 'visible_reply_missing_relayOfMessageId' ||
input.pendingReason === 'visible_reply_still_required' ||
(input.responseState === 'responded_visible_message' && !input.visibleReplyFound)
) {
return control(
input,
'missing_visible_reply_correlation',
input.pendingReason,
messageSendControlLines(input)
);
}
if (
input.pendingReason === 'visible_reply_ack_only_still_requires_answer' ||
input.pendingReason === 'plain_text_ack_only_still_requires_answer'
) {
return control(input, 'visible_answer_required', input.pendingReason, [
'The previous response looked like acknowledgement only, not a concrete answer.',
...messageSendControlLines(input),
]);
}
if (input.responseState === 'tool_error') {
return toolErrorControl(input);
}
if (
input.responseState === 'empty_assistant_turn' ||
input.responseState === 'prompt_delivered_no_assistant_message' ||
input.responseState === 'not_observed' ||
input.responseState === 'reconcile_failed'
) {
return control(
input,
'no_assistant_response',
input.pendingReason,
noAssistantControlLines(input)
);
}
if (
(input.responseState === 'responded_non_visible_tool' ||
input.responseState === 'responded_tool_call') &&
!input.hasKnownProgressProof
) {
return control(
input,
'progress_proof_required',
input.pendingReason,
progressControlLines(input)
);
}
return none(input.pendingReason || 'no_repair_needed');
}