fix: sequence AgentBus roster replies

This commit is contained in:
inman
2026-08-31 16:52:03 +08:00
parent 693aed54aa
commit ffa3340899
8 changed files with 557 additions and 21 deletions

View File

@@ -0,0 +1,29 @@
export const AGENTBUS_GENERIC_ACCEPTED_TEXT = '已受理,正在处理。';
export const AGENTBUS_ROSTER_WAITING_TEXT = '已识别名单业务,等待 .xls 或 .xlsx 名单附件;附件到齐前不会开始解析或执行。';
export const AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT = '名单附件已收到,正在校验并处理。';
export const AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD = '_final_reply_eligible';
export interface AgentBusAcceptedDeliveryOptions {
text?: string;
finalReplyEligible?: boolean;
}
export function createAgentBusAcceptedDeliveryPayload(
options: AgentBusAcceptedDeliveryOptions = {}
): Record<string, unknown> {
const acceptedText = String(options.text || '').trim() || AGENTBUS_GENERIC_ACCEPTED_TEXT;
return {
event: 'task.progress',
status: 'accepted',
text: acceptedText,
[AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD]: options.finalReplyEligible !== false
};
}
export function publicAgentBusDeliveryPayload(
value: Record<string, unknown>
): Record<string, unknown> {
const payload = { ...value };
delete payload[AGENTBUS_FINAL_REPLY_ELIGIBLE_FIELD];
return payload;
}

View File

@@ -19,6 +19,11 @@ import {
parseAgentBusInputAttachment
} from './input-attachment.js';
import { diagnosticDurationMs, diagnosticError } from './diagnostics.js';
import {
AGENTBUS_GENERIC_ACCEPTED_TEXT,
AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
publicAgentBusDeliveryPayload
} from './agentbus-delivery.js';
const OPEN_READY_STATE = 1;
const MAX_COMPLETED_TASK_IDS = 2_048;
@@ -487,7 +492,7 @@ function createDurableDeliveryFrame(
session: AgentBusSession,
directAttachments: AgentBusAttachmentFrame[] = []
): AgentBusFrame {
const payload = { ...jsonObject(delivery.payload) };
const payload = publicAgentBusDeliveryPayload(jsonObject(delivery.payload));
const fallbackAttachments = payloadAttachmentReferences(payload)
.map((reference) => attachmentMetadataFrame(reference))
.filter((attachment): attachment is AgentBusAttachmentFrame => Boolean(attachment));
@@ -581,7 +586,7 @@ export function taskResultText(task: PublicTask): string {
return '资料不完整,请补充必要信息。';
}
if (status === 'awaiting_confirmation') {
return '已受理,正在处理。';
return AGENTBUS_GENERIC_ACCEPTED_TEXT;
}
if (FAILED_TASK_STATUSES.has(status)) {
const failure = task.failure?.failure_message || task.error_summary?.message || task.message;
@@ -982,15 +987,16 @@ export class AgentBusListener {
)
));
}
const durableDelivery = this.durableDeliveryEnabled();
const input: TaskMessageInput = {
message,
conversationId,
taskId: frameTaskId(frame),
...(attachments.length ? { attachments } : {}),
idempotencyKey: this.durableDeliveryEnabled()
idempotencyKey: durableDelivery
? `agentbus:${this.channel?.id}:${taskId}`
: `agentbus:${taskId}`,
...(this.durableDeliveryEnabled() && this.channel
...(durableDelivery && this.channel
? {
channelId: this.channel.id,
agentBusRoute: {
@@ -1001,7 +1007,6 @@ export class AgentBusListener {
}
: {})
};
if (!this.durableDeliveryEnabled()) this.sendProgress(frame, '已受理,正在处理。');
const context: TaskContext = {
organizationId: this.organizationId,
userId: '',
@@ -1019,6 +1024,36 @@ export class AgentBusListener {
attached: result.attached,
created: result.created
}, 'AgentBus message ingested into task service');
const attachmentResult = result.input_attachment || null;
const waitingForRosterAttachment = !attachmentResult && result.task.status === 'awaiting_attachment';
const progressText = attachmentResult?.status === 'duplicate'
? attachmentResult.message
: attachmentResult
? AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT
: waitingForRosterAttachment
? taskResultText(result.task)
: AGENTBUS_GENERIC_ACCEPTED_TEXT;
if (!durableDelivery) {
this.sendProgress(
frame,
progressText,
waitingForRosterAttachment || attachmentResult?.status === 'duplicate'
);
}
if (waitingForRosterAttachment || attachmentResult?.status === 'duplicate') {
this.logger.info({
agentbus_event: waitingForRosterAttachment
? 'roster_attachment_waiting_reply_ready'
: 'attachment_duplicate_reply_ready',
inbound_frame_id: taskId,
task_id: result.task.task_id,
task_status: result.task.status
}, waitingForRosterAttachment
? 'AgentBus roster attachment waiting reply ready'
: 'AgentBus duplicate attachment reply ready');
if (durableDelivery) await this.flushDurableDeliveries();
return;
}
const rejectedAttachment = result.input_attachment?.status === 'rejected'
? result.input_attachment
: null;
@@ -1030,7 +1065,7 @@ export class AgentBusListener {
task_status: result.task.status,
error_code: rejectedAttachment.error_code || 'roster_workbook_processing_failed'
}, 'AgentBus roster attachment rejection reply requested');
if (this.durableDeliveryEnabled() && this.channel && this.tasks.enqueueAgentBusResult) {
if (durableDelivery && this.channel && this.tasks.enqueueAgentBusResult) {
await this.tasks.enqueueAgentBusResult.call(this.tasks, this.channel.id, taskId, {
event: 'task.result',
status: 'failed',
@@ -1048,7 +1083,7 @@ export class AgentBusListener {
inbound_frame_id: taskId,
task_id: result.task.task_id
}, 'AgentBus requesting parse queue');
if (this.durableDeliveryEnabled()) {
if (durableDelivery) {
await this.scheduleParseQueue().catch((error) => {
this.logger.warn({
agentbus_event: 'parse_queue_request_failed',
@@ -1298,7 +1333,11 @@ export class AgentBusListener {
});
}
private sendProgress(inbound: AgentBusFrame, progressText: string): void {
private sendProgress(
inbound: AgentBusFrame,
progressText: string,
completeInbound = false
): void {
if (!this.socket || this.socket.readyState !== OPEN_READY_STATE || !this.session) {
this.logger.warn({
agentbus_event: 'progress_skipped',
@@ -1321,6 +1360,7 @@ export class AgentBusListener {
return;
}
this.logFrame('outbound_progress_sent', frame, 'AgentBus progress frame sent');
if (completeInbound) this.rememberCompleted(text(inbound.id));
});
} catch (error) {
this.logger.warn({

View File

@@ -43,6 +43,12 @@ import {
PASSENGER_ROSTER_WORKBOOK_VERSION,
type NormalizedPassengerRosterWorkbook
} from './passenger-roster-workbook.js';
import {
AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
AGENTBUS_ROSTER_WAITING_TEXT,
createAgentBusAcceptedDeliveryPayload,
type AgentBusAcceptedDeliveryOptions
} from './agentbus-delivery.js';
export interface TaskEvent {
id: number;
@@ -1056,7 +1062,7 @@ export function initialTaskIntakeState(routeId: BusinessRouteId | null): {
? {
status: 'awaiting_attachment',
stage: 'intake',
message: '已识别名单业务,等待 .xls 或 .xlsx 名单附件;附件到齐前不会开始解析或执行。'
message: AGENTBUS_ROSTER_WAITING_TEXT
}
: { status: 'parse_queued', stage: 'parse', message: '任务已入队,等待解析。' };
}
@@ -2107,7 +2113,8 @@ export class TaskService {
channelId: string | undefined,
taskRowId: string,
route: TaskMessageInput['agentBusRoute'] | undefined,
conversationId: string | null
conversationId: string | null,
options: AgentBusAcceptedDeliveryOptions = {}
): Promise<void> {
if (!channelId || !route?.inboundFrameId || !route.inboundFrom) return;
await client.query(
@@ -2123,7 +2130,7 @@ export class TaskService {
route.inboundFrameId,
route.inboundFrom,
route.conversationId || conversationId,
{ event: 'task.progress', status: 'accepted', text: '已受理,正在处理。' }
createAgentBusAcceptedDeliveryPayload(options)
]
);
}
@@ -2910,7 +2917,11 @@ export class TaskService {
target.channelId || undefined,
target.rowId,
selection.agentBusRoute,
target.conversationId
target.conversationId,
{
text: '该名单附件已经接收,无需重复发送。',
finalReplyEligible: false
}
);
return {
row: taskRow,
@@ -3095,7 +3106,11 @@ export class TaskService {
target.channelId || undefined,
target.rowId,
selection.agentBusRoute,
target.conversationId
target.conversationId,
{
text: AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT,
finalReplyEligible: true
}
);
return { row: updatedRow, event, inputAttachment };
});
@@ -3229,11 +3244,16 @@ export class TaskService {
if (!isPassengerRosterRouteId(routeId)) {
throw new TaskError('attachment_not_supported', '只有补充录入名单业务可以接收 Excel 名单附件。', 409);
}
const baseResult = await this.ingestMessage(context, { ...input, attachments: undefined });
const baseResult = await this.ingestMessage(context, {
...input,
attachments: undefined,
agentBusRoute: undefined
});
return this.attachPassengerRosterAttachment(context, attachment, {
taskId: baseResult.task.task_id,
conversationId,
channelId,
agentBusRoute: input.agentBusRoute,
created: baseResult.created
});
}
@@ -3414,7 +3434,13 @@ export class TaskService {
channelId,
text(taskRow.id),
input.agentBusRoute,
conversationId
conversationId,
intake.status === 'awaiting_attachment'
? {
text: intake.message,
finalReplyEligible: false
}
: undefined
);
const event = await this.emitEvent(client, taskRow, {
status: intake.status,
@@ -3554,10 +3580,23 @@ export class TaskService {
WHERE d.channel_id = $1
AND d.delivery_kind = 'accepted'
AND d.delivery_status = 'delivered'
AND (d.payload ->> '_final_reply_eligible') IS DISTINCT FROM 'false'
AND t.status NOT IN (
'awaiting_attachment', 'parse_queued', 'parse_running', 'awaiting_confirmation',
'confirmed', 'queued', 'accepted', 'running'
)
AND NOT EXISTS (
SELECT 1 FROM agentbus_deliveries newer_delivery
WHERE newer_delivery.organization_id = d.organization_id
AND newer_delivery.channel_id = d.channel_id
AND newer_delivery.task_id = d.task_id
AND newer_delivery.delivery_kind = 'accepted'
AND (newer_delivery.payload ->> '_final_reply_eligible') IS DISTINCT FROM 'false'
AND (
newer_delivery.created_at > d.created_at
OR (newer_delivery.created_at = d.created_at AND newer_delivery.id > d.id)
)
)
AND NOT EXISTS (
SELECT 1 FROM agentbus_deliveries result_delivery
WHERE result_delivery.channel_id = d.channel_id