Refactor paired runtime orchestration
This commit is contained in:
244
src/message-agent-executor-paired.ts
Normal file
244
src/message-agent-executor-paired.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { AgentOutput } from './agent-runner.js';
|
||||
import {
|
||||
getLastHumanMessageSender,
|
||||
getLatestTurnNumber,
|
||||
getPairedTaskById,
|
||||
insertPairedTurnOutput,
|
||||
} from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
completePairedExecutionContext,
|
||||
type PreparedPairedExecutionContext,
|
||||
} from './paired-execution-context.js';
|
||||
import { resolveNextTurnAction } from './message-runtime-rules.js';
|
||||
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
|
||||
import { schedulePairedFollowUpOnce } from './paired-follow-up-scheduler.js';
|
||||
import type { PairedRoomRole } from './types.js';
|
||||
|
||||
type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>;
|
||||
|
||||
export interface PairedExecutionLifecycle {
|
||||
updateSummary(args: {
|
||||
outputText?: string | null;
|
||||
errorText?: string | null;
|
||||
}): void;
|
||||
recordFinalOutputBeforeDelivery(outputText: string): void;
|
||||
completeImmediately(args: { status: 'succeeded' | 'failed' }): void;
|
||||
markStatus(status: 'succeeded' | 'failed'): void;
|
||||
markSawOutput(sawOutput: boolean): void;
|
||||
getSummary(): string | null;
|
||||
asyncFinalize(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createPairedExecutionLifecycle(args: {
|
||||
pairedExecutionContext?: PreparedPairedExecutionContext;
|
||||
completedRole: PairedRoomRole;
|
||||
chatJid: string;
|
||||
runId: string;
|
||||
enqueueMessageCheck: () => void;
|
||||
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||
log: ExecutorLog;
|
||||
}): PairedExecutionLifecycle {
|
||||
const {
|
||||
pairedExecutionContext,
|
||||
completedRole,
|
||||
chatJid,
|
||||
runId,
|
||||
enqueueMessageCheck,
|
||||
onOutput,
|
||||
log,
|
||||
} = args;
|
||||
|
||||
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
||||
let pairedExecutionSummary: string | null = null;
|
||||
let pairedFinalOutput: string | null = null;
|
||||
let pairedExecutionCompleted = false;
|
||||
let pairedSawOutput = false;
|
||||
let pairedTurnOutputPersisted = false;
|
||||
|
||||
const persistPairedTurnOutputIfNeeded = () => {
|
||||
if (
|
||||
!pairedExecutionContext ||
|
||||
pairedTurnOutputPersisted ||
|
||||
!pairedFinalOutput ||
|
||||
pairedFinalOutput.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const turnNumber = getLatestTurnNumber(pairedExecutionContext.task.id) + 1;
|
||||
insertPairedTurnOutput(
|
||||
pairedExecutionContext.task.id,
|
||||
turnNumber,
|
||||
completedRole,
|
||||
pairedFinalOutput,
|
||||
);
|
||||
pairedTurnOutputPersisted = true;
|
||||
};
|
||||
|
||||
const completeSuccessfulOwnerTurnBeforeDeliveryIfNeeded = () => {
|
||||
if (
|
||||
completedRole !== 'owner' ||
|
||||
!pairedExecutionContext ||
|
||||
pairedExecutionCompleted ||
|
||||
!pairedFinalOutput ||
|
||||
pairedFinalOutput.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pairedExecutionStatus = 'succeeded';
|
||||
pairedSawOutput = true;
|
||||
persistPairedTurnOutputIfNeeded();
|
||||
completePairedExecutionContext({
|
||||
taskId: pairedExecutionContext.task.id,
|
||||
role: completedRole,
|
||||
status: 'succeeded',
|
||||
summary: pairedExecutionSummary,
|
||||
});
|
||||
pairedExecutionCompleted = true;
|
||||
};
|
||||
|
||||
return {
|
||||
updateSummary({ outputText, errorText }) {
|
||||
if (outputText && outputText.length > 0) {
|
||||
pairedExecutionSummary = outputText.slice(0, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorText && errorText.length > 0) {
|
||||
pairedExecutionSummary = errorText.slice(0, 500);
|
||||
}
|
||||
},
|
||||
|
||||
recordFinalOutputBeforeDelivery(outputText) {
|
||||
pairedFinalOutput = outputText;
|
||||
completeSuccessfulOwnerTurnBeforeDeliveryIfNeeded();
|
||||
persistPairedTurnOutputIfNeeded();
|
||||
},
|
||||
|
||||
completeImmediately({ status }) {
|
||||
if (!pairedExecutionContext || pairedExecutionCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
pairedExecutionStatus = status;
|
||||
if (status === 'succeeded') {
|
||||
persistPairedTurnOutputIfNeeded();
|
||||
}
|
||||
|
||||
completePairedExecutionContext({
|
||||
taskId: pairedExecutionContext.task.id,
|
||||
role: completedRole,
|
||||
status,
|
||||
summary: pairedExecutionSummary,
|
||||
});
|
||||
pairedExecutionCompleted = true;
|
||||
},
|
||||
|
||||
markStatus(status) {
|
||||
pairedExecutionStatus = status;
|
||||
},
|
||||
|
||||
markSawOutput(sawOutput) {
|
||||
pairedSawOutput = sawOutput;
|
||||
},
|
||||
|
||||
getSummary() {
|
||||
return pairedExecutionSummary;
|
||||
},
|
||||
|
||||
async asyncFinalize() {
|
||||
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
||||
const effectiveStatus =
|
||||
completedRole === 'owner' &&
|
||||
pairedExecutionStatus === 'succeeded' &&
|
||||
!pairedSawOutput
|
||||
? 'failed'
|
||||
: pairedExecutionStatus;
|
||||
|
||||
if (effectiveStatus === 'succeeded') {
|
||||
try {
|
||||
persistPairedTurnOutputIfNeeded();
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ pairedTaskId: pairedExecutionContext.task.id, err },
|
||||
'Failed to store paired turn output',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
completePairedExecutionContext({
|
||||
taskId: pairedExecutionContext.task.id,
|
||||
role: completedRole,
|
||||
status: effectiveStatus,
|
||||
summary: pairedExecutionSummary,
|
||||
});
|
||||
pairedExecutionCompleted = true;
|
||||
}
|
||||
|
||||
if (!pairedExecutionContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finishedTask = getPairedTaskById(pairedExecutionContext.task.id);
|
||||
if (finishedTask?.status === 'completed' && finishedTask.completion_reason) {
|
||||
const sender = getLastHumanMessageSender(chatJid);
|
||||
const mention = sender ? `<@${sender}>` : '';
|
||||
const notifications: Record<string, string> = {
|
||||
done: `${mention} ✅ 작업 완료.`,
|
||||
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
|
||||
arbiter_escalated: `${mention} ⚠️ 중재자 판단: 사람 개입이 필요합니다.`,
|
||||
};
|
||||
const message = notifications[finishedTask.completion_reason];
|
||||
if (message) {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
result: message,
|
||||
output: { visibility: 'public', text: message },
|
||||
phase: 'final',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const queueAction = resolvePairedFollowUpQueueAction({
|
||||
completedRole,
|
||||
executionStatus: pairedExecutionStatus,
|
||||
sawOutput: pairedSawOutput,
|
||||
taskStatus: finishedTask?.status ?? null,
|
||||
});
|
||||
if (queueAction !== 'pending' || !finishedTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTurnAction = resolveNextTurnAction({
|
||||
taskStatus: finishedTask.status,
|
||||
lastTurnOutputRole: null,
|
||||
});
|
||||
if (nextTurnAction.kind === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduled = schedulePairedFollowUpOnce({
|
||||
chatJid,
|
||||
runId,
|
||||
task: finishedTask,
|
||||
intentKind: nextTurnAction.kind,
|
||||
enqueue: enqueueMessageCheck,
|
||||
});
|
||||
log.info(
|
||||
{
|
||||
taskId: pairedExecutionContext.task.id,
|
||||
role: completedRole,
|
||||
pairedExecutionStatus,
|
||||
taskStatus: finishedTask.status,
|
||||
intentKind: nextTurnAction.kind,
|
||||
scheduled,
|
||||
},
|
||||
scheduled
|
||||
? 'Queued paired follow-up after failed reviewer/arbiter execution left a pending task state'
|
||||
: 'Skipped duplicate paired follow-up after failed reviewer/arbiter execution in the same run',
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user