feat(member-work-sync): add nudge activation policy
This commit is contained in:
parent
ac2b6c9352
commit
2e6549620f
9 changed files with 609 additions and 44 deletions
|
|
@ -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' };
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
Loading…
Reference in a new issue