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',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { resolveNextTurnAction } from './message-runtime-rules.js';
|
import {
|
||||||
|
resolveFollowUpDispatch,
|
||||||
|
resolveNextTurnAction,
|
||||||
|
} from './message-runtime-rules.js';
|
||||||
import type { PairedRoomRole, PairedTaskStatus } from './types.js';
|
import type { PairedRoomRole, PairedTaskStatus } from './types.js';
|
||||||
export {
|
export {
|
||||||
isRetryableClaudeSessionFailureAttempt,
|
isRetryableClaudeSessionFailureAttempt,
|
||||||
@@ -25,15 +28,15 @@ export function resolvePairedFollowUpQueueAction(args: {
|
|||||||
? args.completedRole
|
? args.completedRole
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
const dispatch = resolveFollowUpDispatch({
|
||||||
if (args.executionStatus === 'succeeded' && args.sawOutput) {
|
source: 'executor-recovery',
|
||||||
return 'none';
|
nextTurnAction,
|
||||||
}
|
completedRole: args.completedRole,
|
||||||
|
executionStatus: args.executionStatus,
|
||||||
const shouldRequeuePendingPairedTurn =
|
sawOutput: args.sawOutput,
|
||||||
(args.completedRole === 'reviewer' || args.completedRole === 'arbiter') &&
|
});
|
||||||
(nextTurnAction.kind === 'reviewer-turn' ||
|
return dispatch.kind === 'enqueue' &&
|
||||||
nextTurnAction.kind === 'arbiter-turn' ||
|
dispatch.queueKind === 'paired-follow-up'
|
||||||
nextTurnAction.kind === 'finalize-owner-turn');
|
? 'pending'
|
||||||
return shouldRequeuePendingPairedTurn ? 'pending' : 'none';
|
: 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ import * as db from './db.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
||||||
import { runAgentForGroup } from './message-agent-executor.js';
|
import { runAgentForGroup } from './message-agent-executor.js';
|
||||||
|
import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js';
|
||||||
import * as pairedExecutionContext from './paired-execution-context.js';
|
import * as pairedExecutionContext from './paired-execution-context.js';
|
||||||
import * as sessionRecovery from './session-recovery.js';
|
import * as sessionRecovery from './session-recovery.js';
|
||||||
import * as serviceRouting from './service-routing.js';
|
import * as serviceRouting from './service-routing.js';
|
||||||
@@ -238,6 +239,7 @@ const ORIGINAL_UNSAFE_HOST_PAIRED_MODE =
|
|||||||
describe('runAgentForGroup room memory', () => {
|
describe('runAgentForGroup room memory', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
resetPairedFollowUpScheduleState();
|
||||||
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
||||||
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
async (_group, _input, _onProcess, onOutput) => {
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
@@ -984,6 +986,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
summary:
|
summary:
|
||||||
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
pairedExecutionContext.completePairedExecutionContext,
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the role plan for reviewer execution while keeping task snapshots on the owner agent', async () => {
|
it('uses the role plan for reviewer execution while keeping task snapshots on the owner agent', async () => {
|
||||||
@@ -1769,11 +1774,107 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect(result).toBe('error');
|
expect(result).toBe('error');
|
||||||
expect(deps.queue.enqueueMessageCheck).toHaveBeenCalledWith('group@test');
|
expect(deps.queue.enqueueMessageCheck).toHaveBeenCalledWith('group@test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not re-enqueue the same review_ready follow-up twice in one run', async () => {
|
||||||
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
|
const deps = makeDeps();
|
||||||
|
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'claude-code',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: null,
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(
|
||||||
|
pairedExecutionContext.preparePairedExecutionContext,
|
||||||
|
).mockReturnValue({
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-review-ready-dedup',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
status: 'in_review',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
workspace: null,
|
||||||
|
envOverrides: {},
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'paired-task-review-ready-dedup',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 1,
|
||||||
|
review_requested_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
|
||||||
|
status: 'error',
|
||||||
|
result: null,
|
||||||
|
error: 'SDK crashed with exit code 1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runAgentForGroup(deps, {
|
||||||
|
group,
|
||||||
|
prompt: 'please review',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-review-ready-requeue-dedup',
|
||||||
|
forcedRole: 'reviewer',
|
||||||
|
onOutput: async () => {},
|
||||||
|
});
|
||||||
|
await runAgentForGroup(deps, {
|
||||||
|
group,
|
||||||
|
prompt: 'please review',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-review-ready-requeue-dedup',
|
||||||
|
forcedRole: 'reviewer',
|
||||||
|
onOutput: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deps.queue.enqueueMessageCheck).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
taskId: 'paired-task-review-ready-dedup',
|
||||||
|
role: 'reviewer',
|
||||||
|
pairedExecutionStatus: 'failed',
|
||||||
|
taskStatus: 'review_ready',
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
scheduled: false,
|
||||||
|
}),
|
||||||
|
'Skipped duplicate paired follow-up after failed reviewer/arbiter execution in the same run',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runAgentForGroup Claude rotation', () => {
|
describe('runAgentForGroup Claude rotation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
resetPairedFollowUpScheduleState();
|
||||||
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(undefined);
|
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(undefined);
|
||||||
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1);
|
vi.mocked(tokenRotation.getTokenCount).mockReturnValue(1);
|
||||||
vi.mocked(tokenRotation.getCurrentTokenIndex).mockReturnValue(0);
|
vi.mocked(tokenRotation.getCurrentTokenIndex).mockReturnValue(0);
|
||||||
@@ -2585,6 +2686,7 @@ describe('runAgentForGroup Claude rotation', () => {
|
|||||||
describe('runAgentForGroup Codex rotation', () => {
|
describe('runAgentForGroup Codex rotation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
resetPairedFollowUpScheduleState();
|
||||||
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(undefined);
|
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(undefined);
|
||||||
vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(2);
|
vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(2);
|
||||||
vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValueOnce(true);
|
vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValueOnce(true);
|
||||||
|
|||||||
@@ -21,22 +21,19 @@ import { listAvailableGroups } from './available-groups.js';
|
|||||||
import {
|
import {
|
||||||
createServiceHandoff,
|
createServiceHandoff,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
getLastHumanMessageSender,
|
|
||||||
getLatestOpenPairedTaskForChat,
|
getLatestOpenPairedTaskForChat,
|
||||||
getLatestTurnNumber,
|
|
||||||
getPairedTaskById,
|
|
||||||
insertPairedTurnOutput,
|
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { createScopedLogger } from './logger.js';
|
import { createScopedLogger } from './logger.js';
|
||||||
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
||||||
import {
|
import {
|
||||||
completePairedExecutionContext,
|
|
||||||
preparePairedExecutionContext,
|
preparePairedExecutionContext,
|
||||||
} from './paired-execution-context.js';
|
} from './paired-execution-context.js';
|
||||||
import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js';
|
import { resolveCodexFallbackHandoff } from './paired-turn-fallback.js';
|
||||||
import { resolveExecutionTarget } from './message-runtime-rules.js';
|
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
|
||||||
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
|
import {
|
||||||
|
resolveExecutionTarget,
|
||||||
|
} from './message-runtime-rules.js';
|
||||||
import { buildRoomRoleContext } from './room-role-context.js';
|
import { buildRoomRoleContext } from './room-role-context.js';
|
||||||
import { type AgentTriggerReason } from './agent-error-detection.js';
|
import { type AgentTriggerReason } from './agent-error-detection.js';
|
||||||
import {
|
import {
|
||||||
@@ -311,55 +308,15 @@ export async function runAgentForGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectivePrompt = moaEnrichedPrompt;
|
const effectivePrompt = moaEnrichedPrompt;
|
||||||
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
const pairedExecutionLifecycle = createPairedExecutionLifecycle({
|
||||||
let pairedExecutionSummary: string | null = null;
|
pairedExecutionContext,
|
||||||
let pairedFinalOutput: string | null = null;
|
completedRole: roomRoleContext?.role ?? 'owner',
|
||||||
let pairedExecutionCompleted = false;
|
chatJid,
|
||||||
let pairedSawOutput = false;
|
runId,
|
||||||
let pairedTurnOutputPersisted = false;
|
enqueueMessageCheck: () => deps.queue.enqueueMessageCheck(chatJid),
|
||||||
|
onOutput,
|
||||||
const persistPairedTurnOutputIfNeeded = (completedRole: PairedRoomRole) => {
|
log,
|
||||||
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 = () => {
|
|
||||||
const completedRole = roomRoleContext?.role ?? 'owner';
|
|
||||||
if (
|
|
||||||
completedRole !== 'owner' ||
|
|
||||||
!pairedExecutionContext ||
|
|
||||||
pairedExecutionCompleted ||
|
|
||||||
!pairedFinalOutput ||
|
|
||||||
pairedFinalOutput.length === 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pairedExecutionStatus = 'succeeded';
|
|
||||||
pairedSawOutput = true;
|
|
||||||
persistPairedTurnOutputIfNeeded(completedRole);
|
|
||||||
completePairedExecutionContext({
|
|
||||||
taskId: pairedExecutionContext.task.id,
|
|
||||||
role: completedRole,
|
|
||||||
status: 'succeeded',
|
|
||||||
summary: pairedExecutionSummary,
|
|
||||||
});
|
|
||||||
pairedExecutionCompleted = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const maybeHandoffToCodex = (
|
const maybeHandoffToCodex = (
|
||||||
reason: AgentTriggerReason,
|
reason: AgentTriggerReason,
|
||||||
@@ -415,7 +372,9 @@ export async function runAgentForGroup(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (pairedExecutionContext?.blockMessage) {
|
if (pairedExecutionContext?.blockMessage) {
|
||||||
pairedExecutionSummary = pairedExecutionContext.blockMessage.slice(0, 500);
|
pairedExecutionLifecycle.updateSummary({
|
||||||
|
outputText: pairedExecutionContext.blockMessage,
|
||||||
|
});
|
||||||
log.warn(
|
log.warn(
|
||||||
{
|
{
|
||||||
roomRoleServiceId: roomRoleContext?.serviceId,
|
roomRoleServiceId: roomRoleContext?.serviceId,
|
||||||
@@ -432,13 +391,7 @@ export async function runAgentForGroup(
|
|||||||
},
|
},
|
||||||
phase: 'final',
|
phase: 'final',
|
||||||
});
|
});
|
||||||
completePairedExecutionContext({
|
pairedExecutionLifecycle.completeImmediately({ status: 'failed' });
|
||||||
taskId: pairedExecutionContext.task.id,
|
|
||||||
role: roomRoleContext?.role ?? 'owner',
|
|
||||||
status: pairedExecutionStatus,
|
|
||||||
summary: pairedExecutionSummary,
|
|
||||||
});
|
|
||||||
pairedExecutionCompleted = true;
|
|
||||||
return 'success';
|
return 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,14 +476,10 @@ export async function runAgentForGroup(
|
|||||||
currentSessionId = output.newSessionId;
|
currentSessionId = output.newSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outputText && outputText.length > 0) {
|
pairedExecutionLifecycle.updateSummary({
|
||||||
pairedExecutionSummary = outputText.slice(0, 500);
|
outputText,
|
||||||
} else if (
|
errorText: typeof output.error === 'string' ? output.error : null,
|
||||||
typeof output.error === 'string' &&
|
});
|
||||||
output.error.length > 0
|
|
||||||
) {
|
|
||||||
pairedExecutionSummary = output.error.slice(0, 500);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
evaluation.newTrigger &&
|
evaluation.newTrigger &&
|
||||||
outputText &&
|
outputText &&
|
||||||
@@ -589,10 +538,8 @@ export async function runAgentForGroup(
|
|||||||
outputText &&
|
outputText &&
|
||||||
outputText.length > 0
|
outputText.length > 0
|
||||||
) {
|
) {
|
||||||
pairedFinalOutput = outputText;
|
|
||||||
try {
|
try {
|
||||||
completeSuccessfulOwnerTurnBeforeDeliveryIfNeeded();
|
pairedExecutionLifecycle.recordFinalOutputBeforeDelivery(outputText);
|
||||||
persistPairedTurnOutputIfNeeded(roomRoleContext?.role ?? 'owner');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(
|
log.warn(
|
||||||
{ pairedTaskId: pairedExecutionContext?.task.id ?? null, err },
|
{ pairedTaskId: pairedExecutionContext?.task.id ?? null, err },
|
||||||
@@ -701,13 +648,13 @@ export async function runAgentForGroup(
|
|||||||
runId,
|
runId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return runClaudeAttemptWithRotation({
|
return runClaudeAttemptWithRotation({
|
||||||
initialTrigger,
|
initialTrigger,
|
||||||
runAttempt: () => runAttempt('claude'),
|
runAttempt: () => runAttempt('claude'),
|
||||||
logContext: logCtx,
|
logContext: logCtx,
|
||||||
rotationMessage,
|
rotationMessage,
|
||||||
onSuccess: ({ sawOutput }) => {
|
onSuccess: ({ sawOutput }) => {
|
||||||
pairedSawOutput = sawOutput;
|
pairedExecutionLifecycle.markSawOutput(sawOutput);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -733,7 +680,7 @@ export async function runAgentForGroup(
|
|||||||
return maybeHandoffAfterError(retryAction.trigger.reason, attempt);
|
return maybeHandoffAfterError(retryAction.trigger.reason, attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
pairedExecutionStatus = 'succeeded';
|
pairedExecutionLifecycle.markStatus('succeeded');
|
||||||
return retryAction.result;
|
return retryAction.result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -755,7 +702,7 @@ export async function runAgentForGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (retryAction.result === 'success') {
|
if (retryAction.result === 'success') {
|
||||||
pairedExecutionStatus = 'succeeded';
|
pairedExecutionLifecycle.markStatus('succeeded');
|
||||||
}
|
}
|
||||||
return retryAction.result;
|
return retryAction.result;
|
||||||
};
|
};
|
||||||
@@ -770,9 +717,6 @@ export async function runAgentForGroup(
|
|||||||
return 'error';
|
return 'error';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPairedExecutionStatus = (): 'succeeded' | 'failed' =>
|
|
||||||
pairedExecutionStatus;
|
|
||||||
|
|
||||||
const isRetryableClaudeSessionFailure = (attempt: AgentAttempt): boolean =>
|
const isRetryableClaudeSessionFailure = (attempt: AgentAttempt): boolean =>
|
||||||
isRetryableClaudeSessionFailureAttempt({
|
isRetryableClaudeSessionFailureAttempt({
|
||||||
attempt,
|
attempt,
|
||||||
@@ -863,15 +807,18 @@ export async function runAgentForGroup(
|
|||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pairedExecutionSummary) {
|
if (!pairedExecutionLifecycle.getSummary()) {
|
||||||
const finalOutputText = getAgentOutputText(output);
|
const finalOutputText = getAgentOutputText(output);
|
||||||
pairedExecutionSummary =
|
pairedExecutionLifecycle.updateSummary({
|
||||||
|
outputText:
|
||||||
(typeof finalOutputText === 'string' && finalOutputText.length > 0
|
(typeof finalOutputText === 'string' && finalOutputText.length > 0
|
||||||
? finalOutputText.slice(0, 500)
|
? finalOutputText
|
||||||
: null) ??
|
: null),
|
||||||
(typeof output.error === 'string' && output.error.length > 0
|
errorText:
|
||||||
? output.error.slice(0, 500)
|
typeof output.error === 'string' && output.error.length > 0
|
||||||
: null);
|
? output.error
|
||||||
|
: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!attempt.sawOutput && output.status !== 'error') {
|
if (!attempt.sawOutput && output.status !== 'error') {
|
||||||
@@ -937,8 +884,8 @@ export async function runAgentForGroup(
|
|||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
pairedExecutionStatus = 'succeeded';
|
pairedExecutionLifecycle.markStatus('succeeded');
|
||||||
pairedSawOutput = attempt.sawOutput;
|
pairedExecutionLifecycle.markSawOutput(attempt.sawOutput);
|
||||||
return 'success';
|
return 'success';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -960,87 +907,6 @@ export async function runAgentForGroup(
|
|||||||
|
|
||||||
return await finalizePrimaryAttempt(primaryAttempt);
|
return await finalizePrimaryAttempt(primaryAttempt);
|
||||||
} finally {
|
} finally {
|
||||||
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
await pairedExecutionLifecycle.asyncFinalize();
|
||||||
const completedRole = roomRoleContext?.role ?? 'owner';
|
|
||||||
const completionStatus = getPairedExecutionStatus();
|
|
||||||
// Owner was interrupted without producing output (e.g. /stop) —
|
|
||||||
// treat as failed so reviewer is not auto-triggered.
|
|
||||||
const effectiveStatus =
|
|
||||||
completedRole === 'owner' &&
|
|
||||||
completionStatus === 'succeeded' &&
|
|
||||||
!pairedSawOutput
|
|
||||||
? 'failed'
|
|
||||||
: completionStatus;
|
|
||||||
if (effectiveStatus === 'succeeded') {
|
|
||||||
try {
|
|
||||||
persistPairedTurnOutputIfNeeded(completedRole);
|
|
||||||
} 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify user when paired task reaches a terminal state that requires attention.
|
|
||||||
if (pairedExecutionContext) {
|
|
||||||
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 args.onOutput?.({
|
|
||||||
status: 'success',
|
|
||||||
result: message,
|
|
||||||
output: { visibility: 'public', text: message },
|
|
||||||
phase: 'final',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failed reviewer/arbiter runs can leave a pending paired task without a
|
|
||||||
// visible bot message. Requeue only those recovery cases here. Successful
|
|
||||||
// owner -> reviewer handoffs are queued by message-runtime after final
|
|
||||||
// delivery succeeds, which avoids waking reviewer off a failed send.
|
|
||||||
if (pairedExecutionContext) {
|
|
||||||
const completedRole = roomRoleContext?.role ?? 'owner';
|
|
||||||
const finishedCheck = getPairedTaskById(pairedExecutionContext.task.id);
|
|
||||||
const queueAction = resolvePairedFollowUpQueueAction({
|
|
||||||
completedRole,
|
|
||||||
executionStatus: pairedExecutionStatus,
|
|
||||||
sawOutput: pairedSawOutput,
|
|
||||||
taskStatus: finishedCheck?.status ?? null,
|
|
||||||
});
|
|
||||||
if (queueAction === 'pending') {
|
|
||||||
deps.queue.enqueueMessageCheck(chatJid);
|
|
||||||
log.info(
|
|
||||||
{
|
|
||||||
taskId: pairedExecutionContext.task.id,
|
|
||||||
role: completedRole,
|
|
||||||
pairedExecutionStatus,
|
|
||||||
taskStatus: finishedCheck?.status ?? null,
|
|
||||||
},
|
|
||||||
'Queued paired follow-up after failed reviewer/arbiter execution left a pending task state',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
199
src/message-runtime-delivery.ts
Normal file
199
src/message-runtime-delivery.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import {
|
||||||
|
markWorkItemDelivered,
|
||||||
|
markWorkItemDeliveryRetry,
|
||||||
|
type WorkItem,
|
||||||
|
} from './db.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { resolveActiveRole } from './message-runtime-rules.js';
|
||||||
|
import { getErrorMessage } from './utils.js';
|
||||||
|
import type { Channel, PairedTask, PairedRoomRole } from './types.js';
|
||||||
|
|
||||||
|
type RuntimeDeliveryLog = Pick<typeof logger, 'info' | 'warn' | 'error'>;
|
||||||
|
|
||||||
|
function buildDeliveryLogContext(
|
||||||
|
channel: Channel,
|
||||||
|
item: WorkItem,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
chatJid: item.chat_jid,
|
||||||
|
channelName: channel.name,
|
||||||
|
workItemId: item.id,
|
||||||
|
deliveryRole: item.delivery_role ?? null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deliverOpenWorkItem(args: {
|
||||||
|
channel: Channel;
|
||||||
|
item: WorkItem;
|
||||||
|
log: RuntimeDeliveryLog;
|
||||||
|
replaceMessageId?: string | null;
|
||||||
|
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
|
||||||
|
openContinuation: (chatJid: string) => void;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const replaceMessageId = args.replaceMessageId ?? null;
|
||||||
|
|
||||||
|
const isDuplicate = args.isDuplicateOfLastBotFinal(
|
||||||
|
args.item.chat_jid,
|
||||||
|
args.item.result_payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
markWorkItemDelivered(args.item.id, null);
|
||||||
|
args.log.info(
|
||||||
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
preview: args.item.result_payload.slice(0, 100),
|
||||||
|
suppressionReason: 'paired-final-duplicate',
|
||||||
|
}),
|
||||||
|
'Suppressed duplicate final message in paired room (marked as delivered)',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (replaceMessageId && args.channel.editMessage) {
|
||||||
|
args.log.info(
|
||||||
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'edit',
|
||||||
|
replacedMessageId: replaceMessageId,
|
||||||
|
}),
|
||||||
|
'Attempting to deliver produced work item by replacing tracked progress message',
|
||||||
|
);
|
||||||
|
await args.channel.editMessage(
|
||||||
|
args.item.chat_jid,
|
||||||
|
replaceMessageId,
|
||||||
|
args.item.result_payload,
|
||||||
|
);
|
||||||
|
markWorkItemDelivered(args.item.id, replaceMessageId);
|
||||||
|
args.openContinuation(args.item.chat_jid);
|
||||||
|
args.log.info(
|
||||||
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'edit',
|
||||||
|
replacedMessageId: replaceMessageId,
|
||||||
|
}),
|
||||||
|
'Delivered produced work item by replacing tracked progress message',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
args.log.warn(
|
||||||
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'edit',
|
||||||
|
replacedMessageId: replaceMessageId,
|
||||||
|
err,
|
||||||
|
}),
|
||||||
|
'Failed to replace tracked progress message; falling back to a new message',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
args.log.info(
|
||||||
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'send',
|
||||||
|
}),
|
||||||
|
'Attempting to deliver produced work item as a new message',
|
||||||
|
);
|
||||||
|
await args.channel.sendMessage(args.item.chat_jid, args.item.result_payload);
|
||||||
|
markWorkItemDelivered(args.item.id);
|
||||||
|
args.openContinuation(args.item.chat_jid);
|
||||||
|
args.log.info(
|
||||||
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'send',
|
||||||
|
}),
|
||||||
|
'Delivered produced work item',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = getErrorMessage(err);
|
||||||
|
markWorkItemDeliveryRetry(args.item.id, errorMessage);
|
||||||
|
args.log.warn(
|
||||||
|
buildDeliveryLogContext(args.channel, args.item, {
|
||||||
|
deliveryAttempts: args.item.delivery_attempts + 1,
|
||||||
|
deliveryMode: 'send',
|
||||||
|
err,
|
||||||
|
}),
|
||||||
|
'Failed to deliver produced work item',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processOpenWorkItemDelivery(args: {
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
openWorkItem: WorkItem | null | undefined;
|
||||||
|
pendingTask: PairedTask | null | undefined;
|
||||||
|
channel: Channel;
|
||||||
|
roleToChannel: Record<'owner' | 'reviewer' | 'arbiter', Channel | null>;
|
||||||
|
log: RuntimeDeliveryLog;
|
||||||
|
isPairedRoom: boolean;
|
||||||
|
getMissingRoleChannelMessage: (role: 'reviewer' | 'arbiter') => string;
|
||||||
|
isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean;
|
||||||
|
openContinuation: (chatJid: string) => void;
|
||||||
|
enqueueFollowUpAfterDeliveryRetry: (args: {
|
||||||
|
deliveryRole: PairedRoomRole;
|
||||||
|
pendingTask: PairedTask | null | undefined;
|
||||||
|
workItemId: string | number;
|
||||||
|
}) => void;
|
||||||
|
}): Promise<'not-found' | 'delivered' | 'failed'> {
|
||||||
|
const { openWorkItem } = args;
|
||||||
|
if (!openWorkItem) {
|
||||||
|
return 'not-found';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.isPairedRoom && openWorkItem.delivery_role == null) {
|
||||||
|
args.log.warn(
|
||||||
|
{
|
||||||
|
workItemId: openWorkItem.id,
|
||||||
|
chatJid: args.chatJid,
|
||||||
|
pendingTaskStatus: args.pendingTask?.status ?? null,
|
||||||
|
},
|
||||||
|
'Paired-room delivery retry is missing a persisted delivery role; falling back to inferred routing',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveryRole =
|
||||||
|
openWorkItem.delivery_role ??
|
||||||
|
(args.pendingTask ? resolveActiveRole(args.pendingTask.status) : 'owner');
|
||||||
|
const deliveryChannel =
|
||||||
|
deliveryRole === 'owner' ? args.channel : args.roleToChannel[deliveryRole];
|
||||||
|
if (!deliveryChannel) {
|
||||||
|
const missingRole = deliveryRole === 'arbiter' ? 'arbiter' : 'reviewer';
|
||||||
|
const errorMessage = args.getMissingRoleChannelMessage(missingRole);
|
||||||
|
markWorkItemDeliveryRetry(openWorkItem.id, errorMessage);
|
||||||
|
args.log.error(
|
||||||
|
{
|
||||||
|
workItemId: openWorkItem.id,
|
||||||
|
role: deliveryRole,
|
||||||
|
requiredChannel:
|
||||||
|
missingRole === 'arbiter' ? 'discord-arbiter' : 'discord-review',
|
||||||
|
},
|
||||||
|
'Unable to deliver paired-room work item because the dedicated Discord role channel is not configured',
|
||||||
|
);
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const delivered = await deliverOpenWorkItem({
|
||||||
|
channel: deliveryChannel,
|
||||||
|
item: openWorkItem,
|
||||||
|
log: args.log,
|
||||||
|
isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal,
|
||||||
|
openContinuation: args.openContinuation,
|
||||||
|
});
|
||||||
|
if (!delivered) {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
args.enqueueFollowUpAfterDeliveryRetry({
|
||||||
|
deliveryRole,
|
||||||
|
pendingTask: args.pendingTask,
|
||||||
|
workItemId: openWorkItem.id,
|
||||||
|
});
|
||||||
|
return 'delivered';
|
||||||
|
}
|
||||||
351
src/message-runtime-dispatch.ts
Normal file
351
src/message-runtime-dispatch.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import { logger } from './logger.js';
|
||||||
|
import {
|
||||||
|
getLatestOpenPairedTaskForChat,
|
||||||
|
getMessagesSinceSeq,
|
||||||
|
} from './db.js';
|
||||||
|
import {
|
||||||
|
buildQueuedTurnDispatch,
|
||||||
|
executeBotOnlyPairedFollowUpAction,
|
||||||
|
} from './message-runtime-flow.js';
|
||||||
|
import {
|
||||||
|
advanceLastAgentCursor,
|
||||||
|
filterLoopingPairedBotMessages,
|
||||||
|
getProcessableMessages,
|
||||||
|
hasAllowedTrigger,
|
||||||
|
resolveCursorKey,
|
||||||
|
resolveFollowUpDispatch,
|
||||||
|
resolveNextTurnAction,
|
||||||
|
shouldSkipBotOnlyCollaboration,
|
||||||
|
} from './message-runtime-rules.js';
|
||||||
|
import { extractSessionCommand, isSessionCommandAllowed } from './session-commands.js';
|
||||||
|
import { isSessionCommandSenderAllowed } from './config.js';
|
||||||
|
import { hasReviewerLease } from './service-routing.js';
|
||||||
|
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
||||||
|
import type {
|
||||||
|
AgentType,
|
||||||
|
Channel,
|
||||||
|
NewMessage,
|
||||||
|
PairedTask,
|
||||||
|
PairedRoomRole,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
type RuntimeLog = Pick<typeof logger, 'info' | 'debug'>;
|
||||||
|
|
||||||
|
type ExecuteTurnFn = (args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
prompt: string;
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
channel: Channel;
|
||||||
|
startSeq: number | null;
|
||||||
|
endSeq: number | null;
|
||||||
|
deliveryRole?: PairedRoomRole;
|
||||||
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
|
}) => Promise<{
|
||||||
|
outputStatus: 'success' | 'error';
|
||||||
|
deliverySucceeded: boolean;
|
||||||
|
visiblePhase: unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function enqueueGenericFollowUpAfterDeliveryRetry(args: {
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
deliveryRole: PairedRoomRole;
|
||||||
|
pendingTask: PairedTask | null | undefined;
|
||||||
|
workItemId: string | number;
|
||||||
|
log: RuntimeLog;
|
||||||
|
enqueueMessageCheck: () => void;
|
||||||
|
schedulePairedFollowUp: (
|
||||||
|
task: PairedTask | null | undefined,
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind,
|
||||||
|
runId: string,
|
||||||
|
) => boolean;
|
||||||
|
}): void {
|
||||||
|
const nextTurnAction =
|
||||||
|
args.pendingTask == null
|
||||||
|
? { kind: 'none' as const }
|
||||||
|
: resolveNextTurnAction({
|
||||||
|
taskStatus: args.pendingTask.status,
|
||||||
|
lastTurnOutputRole: args.deliveryRole,
|
||||||
|
});
|
||||||
|
const dispatch = resolveFollowUpDispatch({
|
||||||
|
source: 'delivery-retry',
|
||||||
|
nextTurnAction,
|
||||||
|
completedRole: args.deliveryRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dispatch.kind === 'none') {
|
||||||
|
args.log.info(
|
||||||
|
{
|
||||||
|
workItemId: args.workItemId,
|
||||||
|
chatJid: args.chatJid,
|
||||||
|
deliveryRole: args.deliveryRole,
|
||||||
|
pendingTaskStatus: args.pendingTask?.status ?? null,
|
||||||
|
},
|
||||||
|
'Skipping queued follow-up after reviewer merge_ready delivery because inline finalize will handle the handoff',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch.kind === 'enqueue' && dispatch.queueKind === 'message-check') {
|
||||||
|
args.enqueueMessageCheck();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dispatch.kind !== 'enqueue' ||
|
||||||
|
dispatch.queueKind !== 'paired-follow-up' ||
|
||||||
|
nextTurnAction.kind === 'none'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduled = args.schedulePairedFollowUp(
|
||||||
|
args.pendingTask,
|
||||||
|
nextTurnAction.kind,
|
||||||
|
args.runId,
|
||||||
|
);
|
||||||
|
if (!scheduled) {
|
||||||
|
args.log.info(
|
||||||
|
{
|
||||||
|
workItemId: args.workItemId,
|
||||||
|
chatJid: args.chatJid,
|
||||||
|
deliveryRole: args.deliveryRole,
|
||||||
|
taskId: args.pendingTask?.id ?? null,
|
||||||
|
taskStatus: args.pendingTask?.status ?? null,
|
||||||
|
intentKind: nextTurnAction.kind,
|
||||||
|
},
|
||||||
|
'Skipped duplicate paired follow-up enqueue after delivery retry in the same run',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processQueuedGroupDispatch(args: {
|
||||||
|
chatJid: string;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
channel: Channel;
|
||||||
|
processableGroupMessages: NewMessage[];
|
||||||
|
assistantName: string;
|
||||||
|
failureFinalText: string;
|
||||||
|
timezone: string;
|
||||||
|
lastAgentTimestamps: Record<string, string>;
|
||||||
|
saveState: () => void;
|
||||||
|
executeTurn: ExecuteTurnFn;
|
||||||
|
schedulePairedFollowUp: (
|
||||||
|
task: PairedTask | null | undefined,
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind,
|
||||||
|
runId: string,
|
||||||
|
) => boolean;
|
||||||
|
enqueueMessageCheck: () => void;
|
||||||
|
sendQueuedMessage: (chatJid: string, text: string) => boolean;
|
||||||
|
closeStdin: (reason: string) => void;
|
||||||
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
||||||
|
formatMessages: (messages: NewMessage[], timezone: string) => string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { chatJid, group, channel, processableGroupMessages } = args;
|
||||||
|
const loopPendingTask = hasReviewerLease(chatJid)
|
||||||
|
? getLatestOpenPairedTaskForChat(chatJid)
|
||||||
|
: null;
|
||||||
|
const loopCursorKey = resolveCursorKey(chatJid, loopPendingTask?.status);
|
||||||
|
const rawPendingMessages = getMessagesSinceSeq(
|
||||||
|
chatJid,
|
||||||
|
args.lastAgentTimestamps[loopCursorKey] || '0',
|
||||||
|
args.assistantName,
|
||||||
|
);
|
||||||
|
const pendingMessages = filterLoopingPairedBotMessages(
|
||||||
|
chatJid,
|
||||||
|
getProcessableMessages(chatJid, rawPendingMessages, channel),
|
||||||
|
args.failureFinalText,
|
||||||
|
);
|
||||||
|
const messagesToSend =
|
||||||
|
pendingMessages.length > 0 ? pendingMessages : processableGroupMessages;
|
||||||
|
const labeledMessagesToSend = args.labelPairedSenders(chatJid, messagesToSend);
|
||||||
|
const {
|
||||||
|
formatted,
|
||||||
|
botOnlyFollowUpAction,
|
||||||
|
isBotOnlyPairedFollowUp,
|
||||||
|
loopCursorKey: dispatchCursorKey,
|
||||||
|
endSeq,
|
||||||
|
} = buildQueuedTurnDispatch({
|
||||||
|
chatJid,
|
||||||
|
timezone: args.timezone,
|
||||||
|
loopPendingTask,
|
||||||
|
rawPendingMessages,
|
||||||
|
messagesToSend,
|
||||||
|
labeledMessagesToSend,
|
||||||
|
formatMessages: args.formatMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const botOnlyFollowUpRunId = `loop-merge-ready-${Date.now().toString(36)}`;
|
||||||
|
if (
|
||||||
|
await executeBotOnlyPairedFollowUpAction({
|
||||||
|
action: botOnlyFollowUpAction,
|
||||||
|
chatJid,
|
||||||
|
group,
|
||||||
|
runId: botOnlyFollowUpRunId,
|
||||||
|
channel,
|
||||||
|
log: logger,
|
||||||
|
saveState: args.saveState,
|
||||||
|
lastAgentTimestamps: args.lastAgentTimestamps,
|
||||||
|
executeTurn: args.executeTurn,
|
||||||
|
schedulePairedFollowUp: (task, intentKind) =>
|
||||||
|
args.schedulePairedFollowUp(task, intentKind, botOnlyFollowUpRunId),
|
||||||
|
closeStdin: () => args.closeStdin('paired-pending-turn-follow-up'),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.sendQueuedMessage(chatJid, formatted)) {
|
||||||
|
if (endSeq != null) {
|
||||||
|
advanceLastAgentCursor(
|
||||||
|
args.lastAgentTimestamps,
|
||||||
|
args.saveState,
|
||||||
|
chatJid,
|
||||||
|
endSeq,
|
||||||
|
dispatchCursorKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
transition: 'typing:on',
|
||||||
|
source: 'follow-up-queued',
|
||||||
|
chatJid,
|
||||||
|
group: group.name,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
endSeq: endSeq ?? null,
|
||||||
|
suppressed: isBotOnlyPairedFollowUp,
|
||||||
|
},
|
||||||
|
'Typing indicator transition',
|
||||||
|
);
|
||||||
|
if (!isBotOnlyPairedFollowUp) {
|
||||||
|
await channel
|
||||||
|
.setTyping?.(chatJid, true)
|
||||||
|
?.catch((err) =>
|
||||||
|
logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
args.enqueueMessageCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processLoopGroupMessages(args: {
|
||||||
|
chatJid: string;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
groupMessages: NewMessage[];
|
||||||
|
channel: Channel;
|
||||||
|
assistantName: string;
|
||||||
|
failureFinalText: string;
|
||||||
|
triggerPattern: RegExp;
|
||||||
|
hasImplicitContinuationWindow: (
|
||||||
|
chatJid: string,
|
||||||
|
messages: NewMessage[],
|
||||||
|
) => boolean;
|
||||||
|
lastAgentTimestamps: Record<string, string>;
|
||||||
|
saveState: () => void;
|
||||||
|
timezone: string;
|
||||||
|
executeTurn: ExecuteTurnFn;
|
||||||
|
schedulePairedFollowUp: (
|
||||||
|
task: PairedTask | null | undefined,
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind,
|
||||||
|
runId: string,
|
||||||
|
) => boolean;
|
||||||
|
enqueueMessageCheck: () => void;
|
||||||
|
sendQueuedMessage: (chatJid: string, text: string) => boolean;
|
||||||
|
closeStdin: (reason: string) => void;
|
||||||
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
||||||
|
formatMessages: (messages: NewMessage[], timezone: string) => string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { chatJid, group, groupMessages, channel } = args;
|
||||||
|
const isMainGroup = group.isMain === true;
|
||||||
|
const processableGroupMessages = getProcessableMessages(
|
||||||
|
chatJid,
|
||||||
|
groupMessages,
|
||||||
|
channel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (processableGroupMessages.length === 0) {
|
||||||
|
const lastIgnored = groupMessages[groupMessages.length - 1];
|
||||||
|
if (lastIgnored?.seq != null) {
|
||||||
|
advanceLastAgentCursor(
|
||||||
|
args.lastAgentTimestamps,
|
||||||
|
args.saveState,
|
||||||
|
chatJid,
|
||||||
|
lastIgnored.seq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSkipBotOnlyCollaboration(chatJid, processableGroupMessages)) {
|
||||||
|
const lastIgnored =
|
||||||
|
processableGroupMessages[processableGroupMessages.length - 1];
|
||||||
|
if (lastIgnored?.seq != null) {
|
||||||
|
advanceLastAgentCursor(
|
||||||
|
args.lastAgentTimestamps,
|
||||||
|
args.saveState,
|
||||||
|
chatJid,
|
||||||
|
lastIgnored.seq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, group: group.name, groupFolder: group.folder },
|
||||||
|
'Bot-collaboration timeout: no recent human message, skipping',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loopCmdMsg = groupMessages.find(
|
||||||
|
(msg) => extractSessionCommand(msg.content, args.triggerPattern) !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loopCmdMsg) {
|
||||||
|
if (
|
||||||
|
isSessionCommandAllowed(
|
||||||
|
isMainGroup,
|
||||||
|
loopCmdMsg.is_from_me === true,
|
||||||
|
isSessionCommandSenderAllowed(loopCmdMsg.sender),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
args.closeStdin('session-command-detected');
|
||||||
|
}
|
||||||
|
args.enqueueMessageCheck();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasAllowedTrigger({
|
||||||
|
chatJid,
|
||||||
|
messages: processableGroupMessages,
|
||||||
|
group,
|
||||||
|
triggerPattern: args.triggerPattern,
|
||||||
|
hasImplicitContinuationWindow: args.hasImplicitContinuationWindow,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processQueuedGroupDispatch({
|
||||||
|
chatJid,
|
||||||
|
group,
|
||||||
|
channel,
|
||||||
|
processableGroupMessages,
|
||||||
|
assistantName: args.assistantName,
|
||||||
|
failureFinalText: args.failureFinalText,
|
||||||
|
timezone: args.timezone,
|
||||||
|
lastAgentTimestamps: args.lastAgentTimestamps,
|
||||||
|
saveState: args.saveState,
|
||||||
|
executeTurn: args.executeTurn,
|
||||||
|
schedulePairedFollowUp: args.schedulePairedFollowUp,
|
||||||
|
enqueueMessageCheck: args.enqueueMessageCheck,
|
||||||
|
sendQueuedMessage: args.sendQueuedMessage,
|
||||||
|
closeStdin: args.closeStdin,
|
||||||
|
labelPairedSenders: args.labelPairedSenders,
|
||||||
|
formatMessages: args.formatMessages,
|
||||||
|
});
|
||||||
|
}
|
||||||
120
src/message-runtime-flow.test.ts
Normal file
120
src/message-runtime-flow.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { executeBotOnlyPairedFollowUpAction } from './message-runtime-flow.js';
|
||||||
|
import {
|
||||||
|
resetPairedFollowUpScheduleState,
|
||||||
|
schedulePairedFollowUpOnce,
|
||||||
|
type ScheduledPairedFollowUpIntentKind,
|
||||||
|
} from './paired-follow-up-scheduler.js';
|
||||||
|
import type { PairedTask } from './types.js';
|
||||||
|
|
||||||
|
describe('executeBotOnlyPairedFollowUpAction', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetPairedFollowUpScheduleState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates bot-only requeue follow-ups within the same run', async () => {
|
||||||
|
const task: PairedTask = {
|
||||||
|
id: 'task-bot-only-dedup',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
round_trip_count: 1,
|
||||||
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const enqueue = vi.fn();
|
||||||
|
const closeStdin = vi.fn();
|
||||||
|
const log = {
|
||||||
|
info: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
const schedulePairedFollowUp = (
|
||||||
|
scheduledTask: PairedTask,
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind,
|
||||||
|
) =>
|
||||||
|
schedulePairedFollowUpOnce({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-bot-only-dedup',
|
||||||
|
task: scheduledTask,
|
||||||
|
intentKind,
|
||||||
|
enqueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
kind: 'requeue-pending-turn' as const,
|
||||||
|
task,
|
||||||
|
cursor: 42,
|
||||||
|
cursorKey: 'group@test',
|
||||||
|
intentKind: 'owner-follow-up' as const,
|
||||||
|
nextRole: 'owner' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await executeBotOnlyPairedFollowUpAction({
|
||||||
|
action,
|
||||||
|
chatJid: 'group@test',
|
||||||
|
group: {
|
||||||
|
name: 'Test Group',
|
||||||
|
folder: 'test-group',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
requiresTrigger: false,
|
||||||
|
agentType: 'codex',
|
||||||
|
},
|
||||||
|
runId: 'run-bot-only-dedup',
|
||||||
|
channel: {} as any,
|
||||||
|
log,
|
||||||
|
saveState: vi.fn(),
|
||||||
|
lastAgentTimestamps: {},
|
||||||
|
executeTurn: vi.fn(),
|
||||||
|
schedulePairedFollowUp,
|
||||||
|
closeStdin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = await executeBotOnlyPairedFollowUpAction({
|
||||||
|
action,
|
||||||
|
chatJid: 'group@test',
|
||||||
|
group: {
|
||||||
|
name: 'Test Group',
|
||||||
|
folder: 'test-group',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
requiresTrigger: false,
|
||||||
|
agentType: 'codex',
|
||||||
|
},
|
||||||
|
runId: 'run-bot-only-dedup',
|
||||||
|
channel: {} as any,
|
||||||
|
log,
|
||||||
|
saveState: vi.fn(),
|
||||||
|
lastAgentTimestamps: {},
|
||||||
|
executeTurn: vi.fn(),
|
||||||
|
schedulePairedFollowUp,
|
||||||
|
closeStdin,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toBe(true);
|
||||||
|
expect(second).toBe(true);
|
||||||
|
expect(closeStdin).toHaveBeenCalledTimes(2);
|
||||||
|
expect(enqueue).toHaveBeenCalledTimes(1);
|
||||||
|
expect(log.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
taskId: 'task-bot-only-dedup',
|
||||||
|
taskStatus: 'active',
|
||||||
|
handoffMode: 'requeue',
|
||||||
|
nextRole: 'owner',
|
||||||
|
intentKind: 'owner-follow-up',
|
||||||
|
scheduled: false,
|
||||||
|
}),
|
||||||
|
'Skipped duplicate paired pending turn requeue in the same run',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
advanceLastAgentCursor,
|
advanceLastAgentCursor,
|
||||||
resolveCursorKey,
|
resolveCursorKey,
|
||||||
|
resolveFollowUpDispatch,
|
||||||
resolveNextTurnAction,
|
resolveNextTurnAction,
|
||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
|
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
||||||
import { hasReviewerLease } from './service-routing.js';
|
import { hasReviewerLease } from './service-routing.js';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
@@ -51,6 +53,7 @@ export type BotOnlyPairedFollowUpAction =
|
|||||||
task: PairedTask;
|
task: PairedTask;
|
||||||
cursor: string | number | null;
|
cursor: string | number | null;
|
||||||
cursorKey: string;
|
cursorKey: string;
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind;
|
||||||
nextRole: 'owner' | 'reviewer' | 'arbiter';
|
nextRole: 'owner' | 'reviewer' | 'arbiter';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -254,8 +257,12 @@ export function resolveBotOnlyPairedFollowUpAction(args: {
|
|||||||
taskStatus: task.status,
|
taskStatus: task.status,
|
||||||
lastTurnOutputRole: lastTurnOutput?.role ?? null,
|
lastTurnOutputRole: lastTurnOutput?.role ?? null,
|
||||||
});
|
});
|
||||||
|
const dispatch = resolveFollowUpDispatch({
|
||||||
|
source: 'bot-only-follow-up',
|
||||||
|
nextTurnAction,
|
||||||
|
});
|
||||||
|
|
||||||
if (nextTurnAction.kind === 'none') {
|
if (dispatch.kind === 'none') {
|
||||||
return {
|
return {
|
||||||
kind: 'consume-stale-bot-message',
|
kind: 'consume-stale-bot-message',
|
||||||
task,
|
task,
|
||||||
@@ -264,7 +271,7 @@ export function resolveBotOnlyPairedFollowUpAction(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextTurnAction.kind === 'finalize-owner-turn') {
|
if (dispatch.kind === 'inline') {
|
||||||
return {
|
return {
|
||||||
kind: 'inline-finalize',
|
kind: 'inline-finalize',
|
||||||
task,
|
task,
|
||||||
@@ -273,15 +280,16 @@ export function resolveBotOnlyPairedFollowUpAction(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
nextTurnAction.kind === 'owner-follow-up' ||
|
dispatch.kind === 'enqueue' &&
|
||||||
nextTurnAction.kind === 'reviewer-turn' ||
|
dispatch.queueKind === 'paired-follow-up' &&
|
||||||
nextTurnAction.kind === 'arbiter-turn'
|
nextTurnAction.kind !== 'none'
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
kind: 'requeue-pending-turn',
|
kind: 'requeue-pending-turn',
|
||||||
task,
|
task,
|
||||||
cursor,
|
cursor,
|
||||||
cursorKey: resolveCursorKey(chatJid, task.status),
|
cursorKey: resolveCursorKey(chatJid, task.status),
|
||||||
|
intentKind: nextTurnAction.kind,
|
||||||
nextRole:
|
nextRole:
|
||||||
nextTurnAction.kind === 'owner-follow-up'
|
nextTurnAction.kind === 'owner-follow-up'
|
||||||
? 'owner'
|
? 'owner'
|
||||||
@@ -312,7 +320,10 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
|
|||||||
startSeq: number | null;
|
startSeq: number | null;
|
||||||
endSeq: number | null;
|
endSeq: number | null;
|
||||||
}) => Promise<{ deliverySucceeded: boolean }>;
|
}) => Promise<{ deliverySucceeded: boolean }>;
|
||||||
enqueueGroupMessageCheck: () => void;
|
schedulePairedFollowUp: (
|
||||||
|
task: PairedTask,
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind,
|
||||||
|
) => boolean;
|
||||||
closeStdin: () => void;
|
closeStdin: () => void;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const {
|
const {
|
||||||
@@ -325,7 +336,7 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
|
|||||||
saveState,
|
saveState,
|
||||||
lastAgentTimestamps,
|
lastAgentTimestamps,
|
||||||
executeTurn,
|
executeTurn,
|
||||||
enqueueGroupMessageCheck,
|
schedulePairedFollowUp,
|
||||||
closeStdin,
|
closeStdin,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
@@ -384,13 +395,13 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
|
|||||||
endSeq: null,
|
endSeq: null,
|
||||||
});
|
});
|
||||||
if (!deliverySucceeded) {
|
if (!deliverySucceeded) {
|
||||||
enqueueGroupMessageCheck();
|
schedulePairedFollowUp(action.task, 'finalize-owner-turn');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeStdin();
|
closeStdin();
|
||||||
enqueueGroupMessageCheck();
|
const scheduled = schedulePairedFollowUp(action.task, action.intentKind);
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -400,10 +411,14 @@ export async function executeBotOnlyPairedFollowUpAction(args: {
|
|||||||
taskStatus: action.task.status,
|
taskStatus: action.task.status,
|
||||||
handoffMode: 'requeue',
|
handoffMode: 'requeue',
|
||||||
nextRole: action.nextRole,
|
nextRole: action.nextRole,
|
||||||
|
intentKind: action.intentKind,
|
||||||
cursor: action.cursor,
|
cursor: action.cursor,
|
||||||
cursorKey: action.cursorKey,
|
cursorKey: action.cursorKey,
|
||||||
|
scheduled,
|
||||||
},
|
},
|
||||||
'Queued fresh paired pending turn instead of piping bot-only follow-up into the active agent',
|
scheduled
|
||||||
|
? 'Queued fresh paired pending turn instead of piping bot-only follow-up into the active agent'
|
||||||
|
: 'Skipped duplicate paired pending turn requeue in the same run',
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -454,17 +469,3 @@ export function buildQueuedTurnDispatch(args: {
|
|||||||
endSeq: args.messagesToSend[args.messagesToSend.length - 1]?.seq ?? null,
|
endSeq: args.messagesToSend[args.messagesToSend.length - 1]?.seq ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldSkipGenericFollowUpAfterDeliveryRetry(args: {
|
|
||||||
chatJid: string;
|
|
||||||
deliveryRole: PairedRoomRole;
|
|
||||||
pendingTask: PairedTask | null | undefined;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
hasReviewerLease(args.chatJid) &&
|
|
||||||
args.deliveryRole === 'reviewer' &&
|
|
||||||
resolveNextTurnAction({
|
|
||||||
taskStatus: args.pendingTask?.status ?? null,
|
|
||||||
}).kind === 'finalize-owner-turn'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
49
src/message-runtime-gating.ts
Normal file
49
src/message-runtime-gating.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { logger } from './logger.js';
|
||||||
|
import { hasAllowedTrigger } from './message-runtime-rules.js';
|
||||||
|
import { handleSessionCommand, type SessionCommandDeps } from './session-commands.js';
|
||||||
|
import type { NewMessage, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
|
export async function handleQueuedRunGates(args: {
|
||||||
|
chatJid: string;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
runId: string;
|
||||||
|
missedMessages: NewMessage[];
|
||||||
|
triggerPattern: RegExp;
|
||||||
|
timezone: string;
|
||||||
|
hasImplicitContinuationWindow: (
|
||||||
|
chatJid: string,
|
||||||
|
messages: NewMessage[],
|
||||||
|
) => boolean;
|
||||||
|
sessionCommandDeps: SessionCommandDeps;
|
||||||
|
}): Promise<{ handled: true; success: boolean } | { handled: false }> {
|
||||||
|
const cmdResult = await handleSessionCommand({
|
||||||
|
missedMessages: args.missedMessages,
|
||||||
|
isMainGroup: args.group.isMain === true,
|
||||||
|
groupName: args.group.name,
|
||||||
|
runId: args.runId,
|
||||||
|
triggerPattern: args.triggerPattern,
|
||||||
|
timezone: args.timezone,
|
||||||
|
deps: args.sessionCommandDeps,
|
||||||
|
});
|
||||||
|
if (cmdResult.handled) {
|
||||||
|
return cmdResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasAllowedTrigger({
|
||||||
|
chatJid: args.chatJid,
|
||||||
|
messages: args.missedMessages,
|
||||||
|
group: args.group,
|
||||||
|
triggerPattern: args.triggerPattern,
|
||||||
|
hasImplicitContinuationWindow: args.hasImplicitContinuationWindow,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
{ chatJid: args.chatJid, group: args.group.name, runId: args.runId },
|
||||||
|
'Skipping queued run because no allowed trigger was found',
|
||||||
|
);
|
||||||
|
return { handled: true, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handled: false };
|
||||||
|
}
|
||||||
173
src/message-runtime-handoffs.ts
Normal file
173
src/message-runtime-handoffs.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { getErrorMessage } from './utils.js';
|
||||||
|
import {
|
||||||
|
claimServiceHandoff,
|
||||||
|
completeServiceHandoffAndAdvanceTargetCursor,
|
||||||
|
failServiceHandoff,
|
||||||
|
getAllPendingServiceHandoffs,
|
||||||
|
type ServiceHandoff,
|
||||||
|
} from './db.js';
|
||||||
|
import { findChannel, findChannelByName } from './router.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import {
|
||||||
|
getFixedRoleChannelName,
|
||||||
|
getMissingRoleChannelMessage,
|
||||||
|
resolveHandoffCursorKey,
|
||||||
|
resolveHandoffRoleOverride,
|
||||||
|
} from './message-runtime-shared.js';
|
||||||
|
import type {
|
||||||
|
AgentType,
|
||||||
|
Channel,
|
||||||
|
PairedRoomRole,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
type ExecuteTurnFn = (args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
prompt: string;
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
channel: Channel;
|
||||||
|
startSeq: number | null;
|
||||||
|
endSeq: number | null;
|
||||||
|
deliveryRole?: PairedRoomRole;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
|
}) => Promise<{
|
||||||
|
outputStatus: 'success' | 'error';
|
||||||
|
deliverySucceeded: boolean;
|
||||||
|
visiblePhase: unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function enqueuePendingHandoffs(args: {
|
||||||
|
enqueueTask: (
|
||||||
|
chatJid: string,
|
||||||
|
taskId: string,
|
||||||
|
task: () => Promise<void>,
|
||||||
|
) => void;
|
||||||
|
processClaimedHandoff: (handoff: ServiceHandoff) => Promise<void>;
|
||||||
|
}): void {
|
||||||
|
for (const handoff of getAllPendingServiceHandoffs()) {
|
||||||
|
if (!claimServiceHandoff(handoff.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
args.enqueueTask(handoff.chat_jid, `handoff:${handoff.id}`, async () => {
|
||||||
|
await args.processClaimedHandoff(handoff);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processClaimedHandoff(args: {
|
||||||
|
handoff: ServiceHandoff;
|
||||||
|
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
channels: Channel[];
|
||||||
|
executeTurn: ExecuteTurnFn;
|
||||||
|
lastAgentTimestamps: Record<string, string>;
|
||||||
|
saveState: () => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { handoff } = args;
|
||||||
|
const group = args.getRegisteredGroups()[handoff.chat_jid];
|
||||||
|
if (!group) {
|
||||||
|
failServiceHandoff(handoff.id, 'Group not registered on target service');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = findChannel(args.channels, handoff.chat_jid);
|
||||||
|
if (!channel) {
|
||||||
|
failServiceHandoff(handoff.id, 'No channel owns handoff jid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handoffRole = resolveHandoffRoleOverride(handoff);
|
||||||
|
let handoffChannel = channel;
|
||||||
|
if (handoffRole === 'reviewer') {
|
||||||
|
const reviewerChannel = findChannelByName(
|
||||||
|
args.channels,
|
||||||
|
getFixedRoleChannelName('reviewer'),
|
||||||
|
);
|
||||||
|
if (!reviewerChannel) {
|
||||||
|
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('reviewer'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handoffChannel = reviewerChannel;
|
||||||
|
} else if (handoffRole === 'arbiter') {
|
||||||
|
const arbiterChannel = findChannelByName(
|
||||||
|
args.channels,
|
||||||
|
getFixedRoleChannelName('arbiter'),
|
||||||
|
);
|
||||||
|
if (!arbiterChannel) {
|
||||||
|
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('arbiter'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handoffChannel = arbiterChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = `handoff-${handoff.id}`;
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid: handoff.chat_jid,
|
||||||
|
handoffId: handoff.id,
|
||||||
|
runId,
|
||||||
|
handoffRole,
|
||||||
|
targetRole: handoff.target_role ?? null,
|
||||||
|
targetServiceId: handoff.target_service_id,
|
||||||
|
targetAgentType: handoff.target_agent_type,
|
||||||
|
reason: handoff.reason,
|
||||||
|
intendedRole: handoff.intended_role ?? null,
|
||||||
|
channelName: handoffChannel.name,
|
||||||
|
},
|
||||||
|
'Dispatching claimed service handoff',
|
||||||
|
);
|
||||||
|
const result = await args.executeTurn({
|
||||||
|
group,
|
||||||
|
prompt: handoff.prompt,
|
||||||
|
chatJid: handoff.chat_jid,
|
||||||
|
runId,
|
||||||
|
channel: handoffChannel,
|
||||||
|
startSeq: handoff.start_seq,
|
||||||
|
endSeq: handoff.end_seq,
|
||||||
|
forcedRole: handoffRole,
|
||||||
|
forcedAgentType: handoff.target_agent_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.deliverySucceeded) {
|
||||||
|
failServiceHandoff(handoff.id, 'Handoff delivery failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorKey = resolveHandoffCursorKey(handoff.chat_jid, handoffRole);
|
||||||
|
const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({
|
||||||
|
id: handoff.id,
|
||||||
|
chat_jid: handoff.chat_jid,
|
||||||
|
cursor_key: cursorKey,
|
||||||
|
end_seq: handoff.end_seq,
|
||||||
|
});
|
||||||
|
if (appliedCursor) {
|
||||||
|
args.lastAgentTimestamps[cursorKey] = appliedCursor;
|
||||||
|
args.saveState();
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid: handoff.chat_jid,
|
||||||
|
handoffId: handoff.id,
|
||||||
|
runId,
|
||||||
|
outputStatus: result.outputStatus,
|
||||||
|
visiblePhase: result.visiblePhase,
|
||||||
|
appliedCursor,
|
||||||
|
cursorKey:
|
||||||
|
appliedCursor != null
|
||||||
|
? resolveHandoffCursorKey(handoff.chat_jid, handoffRole)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
'Completed claimed service handoff',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = getErrorMessage(err);
|
||||||
|
failServiceHandoff(handoff.id, errorMessage);
|
||||||
|
logger.error(
|
||||||
|
{ chatJid: handoff.chat_jid, handoffId: handoff.id, err },
|
||||||
|
'Claimed service handoff failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/message-runtime-loop.ts
Normal file
201
src/message-runtime-loop.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
getMessagesSinceSeq,
|
||||||
|
getNewMessagesBySeq,
|
||||||
|
getOpenWorkItemForChat,
|
||||||
|
} from './db.js';
|
||||||
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { processLoopGroupMessages } from './message-runtime-dispatch.js';
|
||||||
|
import {
|
||||||
|
advanceLastAgentCursor,
|
||||||
|
getProcessableMessages,
|
||||||
|
} from './message-runtime-rules.js';
|
||||||
|
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
||||||
|
import { findChannel, formatMessages } from './router.js';
|
||||||
|
import type {
|
||||||
|
AgentType,
|
||||||
|
Channel,
|
||||||
|
NewMessage,
|
||||||
|
PairedRoomRole,
|
||||||
|
PairedTask,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
type ExecuteTurnFn = (args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
prompt: string;
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
channel: Channel;
|
||||||
|
startSeq: number | null;
|
||||||
|
endSeq: number | null;
|
||||||
|
deliveryRole?: PairedRoomRole;
|
||||||
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
|
}) => Promise<{
|
||||||
|
outputStatus: 'success' | 'error';
|
||||||
|
deliverySucceeded: boolean;
|
||||||
|
visiblePhase: unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function processMessageLoopTick(args: {
|
||||||
|
assistantName: string;
|
||||||
|
failureFinalText: string;
|
||||||
|
triggerPattern: RegExp;
|
||||||
|
timezone: string;
|
||||||
|
channels: Channel[];
|
||||||
|
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
getLastTimestamp: () => string;
|
||||||
|
setLastTimestamp: (timestamp: string) => void;
|
||||||
|
lastAgentTimestamps: Record<string, string>;
|
||||||
|
saveState: () => void;
|
||||||
|
hasImplicitContinuationWindow: (
|
||||||
|
chatJid: string,
|
||||||
|
messages: NewMessage[],
|
||||||
|
) => boolean;
|
||||||
|
executeTurn: ExecuteTurnFn;
|
||||||
|
enqueuePendingHandoffs: () => void;
|
||||||
|
scheduleQueuedPairedFollowUp: (args: {
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
task: PairedTask | null | undefined;
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind;
|
||||||
|
enqueue: () => void;
|
||||||
|
}) => boolean;
|
||||||
|
enqueueScopedGroupMessageCheck: (chatJid: string, groupFolder: string) => void;
|
||||||
|
sendQueuedMessage: (chatJid: string, text: string) => boolean;
|
||||||
|
closeStdin: (chatJid: string, reason: string) => void;
|
||||||
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
||||||
|
}): Promise<void> {
|
||||||
|
args.enqueuePendingHandoffs();
|
||||||
|
const registeredGroups = args.getRegisteredGroups();
|
||||||
|
const jids = Object.keys(registeredGroups);
|
||||||
|
const { messages, newSeqCursor } = getNewMessagesBySeq(
|
||||||
|
jids,
|
||||||
|
args.getLastTimestamp(),
|
||||||
|
args.assistantName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ count: messages.length }, 'New messages');
|
||||||
|
args.setLastTimestamp(newSeqCursor);
|
||||||
|
args.saveState();
|
||||||
|
|
||||||
|
const messagesByGroup = new Map<string, NewMessage[]>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
const existing = messagesByGroup.get(msg.chat_jid);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(msg);
|
||||||
|
} else {
|
||||||
|
messagesByGroup.set(msg.chat_jid, [msg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [chatJid, groupMessages] of messagesByGroup) {
|
||||||
|
const group = registeredGroups[chatJid];
|
||||||
|
if (!group) continue;
|
||||||
|
|
||||||
|
const channel = findChannel(args.channels, chatJid);
|
||||||
|
if (!channel) {
|
||||||
|
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processLoopGroupMessages({
|
||||||
|
chatJid,
|
||||||
|
group,
|
||||||
|
groupMessages,
|
||||||
|
channel,
|
||||||
|
assistantName: args.assistantName,
|
||||||
|
failureFinalText: args.failureFinalText,
|
||||||
|
triggerPattern: args.triggerPattern,
|
||||||
|
hasImplicitContinuationWindow: args.hasImplicitContinuationWindow,
|
||||||
|
lastAgentTimestamps: args.lastAgentTimestamps,
|
||||||
|
saveState: args.saveState,
|
||||||
|
timezone: args.timezone,
|
||||||
|
executeTurn: args.executeTurn,
|
||||||
|
schedulePairedFollowUp: (task, intentKind, followUpRunId) =>
|
||||||
|
args.scheduleQueuedPairedFollowUp({
|
||||||
|
chatJid,
|
||||||
|
runId: followUpRunId,
|
||||||
|
task,
|
||||||
|
intentKind,
|
||||||
|
enqueue: () =>
|
||||||
|
args.enqueueScopedGroupMessageCheck(chatJid, group.folder),
|
||||||
|
}),
|
||||||
|
enqueueMessageCheck: () =>
|
||||||
|
args.enqueueScopedGroupMessageCheck(chatJid, group.folder),
|
||||||
|
sendQueuedMessage: args.sendQueuedMessage,
|
||||||
|
closeStdin: (reason) => args.closeStdin(chatJid, reason),
|
||||||
|
labelPairedSenders: args.labelPairedSenders,
|
||||||
|
formatMessages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverPendingMessages(args: {
|
||||||
|
assistantName: string;
|
||||||
|
channels: Channel[];
|
||||||
|
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
lastAgentTimestamps: Record<string, string>;
|
||||||
|
saveState: () => void;
|
||||||
|
enqueueScopedGroupMessageCheck: (chatJid: string, groupFolder: string) => void;
|
||||||
|
}): void {
|
||||||
|
const registeredGroups = args.getRegisteredGroups();
|
||||||
|
for (const [chatJid, group] of Object.entries(registeredGroups)) {
|
||||||
|
const openWorkItem = getOpenWorkItemForChat(chatJid);
|
||||||
|
if (openWorkItem) {
|
||||||
|
logger.info(
|
||||||
|
{ chatJid, group: group.name, workItemId: openWorkItem.id },
|
||||||
|
'Recovery: found open work item awaiting delivery',
|
||||||
|
);
|
||||||
|
args.enqueueScopedGroupMessageCheck(chatJid, group.folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sinceSeqCursor = args.lastAgentTimestamps[chatJid] || '';
|
||||||
|
const rawPending = getMessagesSinceSeq(
|
||||||
|
chatJid,
|
||||||
|
sinceSeqCursor,
|
||||||
|
args.assistantName,
|
||||||
|
);
|
||||||
|
const recoveryChannel = findChannel(args.channels, chatJid);
|
||||||
|
const pending = getProcessableMessages(
|
||||||
|
chatJid,
|
||||||
|
rawPending,
|
||||||
|
recoveryChannel ?? undefined,
|
||||||
|
);
|
||||||
|
if (pending.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
{ group: group.name, pendingCount: pending.length },
|
||||||
|
'Recovery: found unprocessed messages',
|
||||||
|
);
|
||||||
|
args.enqueueScopedGroupMessageCheck(chatJid, group.folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawPending.length > 0) {
|
||||||
|
const endSeq = rawPending[rawPending.length - 1].seq;
|
||||||
|
if (endSeq != null) {
|
||||||
|
advanceLastAgentCursor(
|
||||||
|
args.lastAgentTimestamps,
|
||||||
|
args.saveState,
|
||||||
|
chatJid,
|
||||||
|
endSeq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildScopedMessageCheckEnqueuer(queue: {
|
||||||
|
enqueueMessageCheck: (chatJid: string, ipcDir?: string) => void;
|
||||||
|
}): (chatJid: string, groupFolder: string) => void {
|
||||||
|
return (chatJid: string, groupFolder: string): void => {
|
||||||
|
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder));
|
||||||
|
};
|
||||||
|
}
|
||||||
244
src/message-runtime-queue.ts
Normal file
244
src/message-runtime-queue.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
getPairedTurnOutputs,
|
||||||
|
getRecentChatMessages,
|
||||||
|
} from './db.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import {
|
||||||
|
buildArbiterPromptForTask,
|
||||||
|
buildPairedTurnPrompt,
|
||||||
|
} from './message-runtime-prompts.js';
|
||||||
|
import {
|
||||||
|
buildPendingPairedTurn,
|
||||||
|
executePendingPairedTurn,
|
||||||
|
isBotOnlyPairedRoomTurn,
|
||||||
|
} from './message-runtime-flow.js';
|
||||||
|
import {
|
||||||
|
advanceLastAgentCursor,
|
||||||
|
resolveActiveRole,
|
||||||
|
resolveCursorKeyForRole,
|
||||||
|
resolveQueuedTurnRole,
|
||||||
|
} from './message-runtime-rules.js';
|
||||||
|
import type {
|
||||||
|
AgentType,
|
||||||
|
Channel,
|
||||||
|
NewMessage,
|
||||||
|
PairedRoomRole,
|
||||||
|
PairedTask,
|
||||||
|
RegisteredGroup,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
type ExecuteTurnFn = (args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
prompt: string;
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
channel: Channel;
|
||||||
|
startSeq: number | null;
|
||||||
|
endSeq: number | null;
|
||||||
|
deliveryRole?: PairedRoomRole;
|
||||||
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
|
}) => Promise<{
|
||||||
|
outputStatus: 'success' | 'error';
|
||||||
|
deliverySucceeded: boolean;
|
||||||
|
visiblePhase: unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type RoleToChannelMap = Record<'owner' | 'reviewer' | 'arbiter', Channel | null>;
|
||||||
|
|
||||||
|
export async function runPendingPairedTurnIfNeeded(args: {
|
||||||
|
chatJid: string;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
runId: string;
|
||||||
|
log: typeof logger;
|
||||||
|
timezone: string;
|
||||||
|
task: PairedTask | null | undefined;
|
||||||
|
rawMissedMessages: NewMessage[];
|
||||||
|
saveState: () => void;
|
||||||
|
lastAgentTimestamps: Record<string, string>;
|
||||||
|
executeTurn: ExecuteTurnFn;
|
||||||
|
getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string;
|
||||||
|
roleToChannel: RoleToChannelMap;
|
||||||
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
||||||
|
mode: 'idle' | 'bot-only';
|
||||||
|
missedMessages?: NewMessage[];
|
||||||
|
}): Promise<boolean | null> {
|
||||||
|
const { chatJid, task, roleToChannel } = args;
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
args.mode === 'bot-only' &&
|
||||||
|
(!args.missedMessages ||
|
||||||
|
!isBotOnlyPairedRoomTurn(chatJid, args.missedMessages))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentMessages = getRecentChatMessages(chatJid, 20);
|
||||||
|
const recentHumanMessages = recentMessages.filter(
|
||||||
|
(message) => !message.is_bot_message,
|
||||||
|
);
|
||||||
|
const labeledRecentMessages = args.labelPairedSenders(
|
||||||
|
chatJid,
|
||||||
|
recentMessages,
|
||||||
|
);
|
||||||
|
const pendingTurn = buildPendingPairedTurn({
|
||||||
|
chatJid,
|
||||||
|
timezone: args.timezone,
|
||||||
|
task,
|
||||||
|
rawMissedMessages: args.rawMissedMessages,
|
||||||
|
recentHumanMessages,
|
||||||
|
labeledRecentMessages,
|
||||||
|
resolveChannel: (taskStatus) =>
|
||||||
|
roleToChannel[resolveActiveRole(taskStatus)] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pendingTurn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return executePendingPairedTurn({
|
||||||
|
pendingTurn,
|
||||||
|
chatJid,
|
||||||
|
group: args.group,
|
||||||
|
runId: args.runId,
|
||||||
|
log: args.log,
|
||||||
|
saveState: args.saveState,
|
||||||
|
lastAgentTimestamps: args.lastAgentTimestamps,
|
||||||
|
executeTurn: args.executeTurn,
|
||||||
|
getFixedRoleChannelName: args.getFixedRoleChannelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runQueuedGroupTurn(args: {
|
||||||
|
chatJid: string;
|
||||||
|
group: RegisteredGroup;
|
||||||
|
runId: string;
|
||||||
|
log: typeof logger;
|
||||||
|
timezone: string;
|
||||||
|
missedMessages: NewMessage[];
|
||||||
|
task: PairedTask | null | undefined;
|
||||||
|
roleToChannel: RoleToChannelMap;
|
||||||
|
ownerChannel: Channel;
|
||||||
|
lastAgentTimestamps: Record<string, string>;
|
||||||
|
saveState: () => void;
|
||||||
|
executeTurn: ExecuteTurnFn;
|
||||||
|
getFixedRoleChannelName: (role: 'reviewer' | 'arbiter') => string;
|
||||||
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
||||||
|
formatMessages: (messages: NewMessage[], timezone: string) => string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { chatJid, group, runId, log, missedMessages, task, roleToChannel } =
|
||||||
|
args;
|
||||||
|
const taskStatus = task?.status;
|
||||||
|
const hasHumanMsg = !isBotOnlyPairedRoomTurn(chatJid, missedMessages);
|
||||||
|
const turnRole = task
|
||||||
|
? resolveQueuedTurnRole({
|
||||||
|
taskStatus,
|
||||||
|
hasHumanMessage: hasHumanMsg,
|
||||||
|
})
|
||||||
|
: 'owner';
|
||||||
|
const turnChannel =
|
||||||
|
turnRole === 'owner' ? args.ownerChannel : roleToChannel[turnRole];
|
||||||
|
const cursorKey = resolveCursorKeyForRole(chatJid, turnRole);
|
||||||
|
const forcedRole =
|
||||||
|
task && turnRole !== resolveActiveRole(taskStatus) ? turnRole : undefined;
|
||||||
|
|
||||||
|
let prompt: string;
|
||||||
|
if (turnRole === 'arbiter' && task) {
|
||||||
|
const recentMessages = getRecentChatMessages(chatJid, 20);
|
||||||
|
prompt = buildArbiterPromptForTask({
|
||||||
|
task,
|
||||||
|
chatJid,
|
||||||
|
timezone: args.timezone,
|
||||||
|
turnOutputs: getPairedTurnOutputs(task.id),
|
||||||
|
recentMessages,
|
||||||
|
labeledRecentMessages: args.labelPairedSenders(chatJid, recentMessages),
|
||||||
|
});
|
||||||
|
} else if (task) {
|
||||||
|
prompt = buildPairedTurnPrompt({
|
||||||
|
taskId: task.id,
|
||||||
|
chatJid,
|
||||||
|
timezone: args.timezone,
|
||||||
|
missedMessages,
|
||||||
|
labeledFallbackMessages: args.labelPairedSenders(chatJid, missedMessages),
|
||||||
|
turnOutputs: getPairedTurnOutputs(task.id),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
prompt = args.formatMessages(
|
||||||
|
args.labelPairedSenders(chatJid, missedMessages),
|
||||||
|
args.timezone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSeq = missedMessages[0].seq ?? null;
|
||||||
|
const endSeq = missedMessages[missedMessages.length - 1].seq ?? null;
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
messageCount: missedMessages.length,
|
||||||
|
messageSeqStart: startSeq,
|
||||||
|
messageSeqEnd: endSeq,
|
||||||
|
},
|
||||||
|
'Dispatching queued messages to agent',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!turnChannel) {
|
||||||
|
const missingRole = turnRole === 'arbiter' ? 'arbiter' : 'reviewer';
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
taskStatus,
|
||||||
|
role: turnRole,
|
||||||
|
requiredChannel: args.getFixedRoleChannelName(missingRole),
|
||||||
|
},
|
||||||
|
'Skipping paired-room run because the dedicated Discord role channel is not configured',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endSeq !== null) {
|
||||||
|
advanceLastAgentCursor(
|
||||||
|
args.lastAgentTimestamps,
|
||||||
|
args.saveState,
|
||||||
|
chatJid,
|
||||||
|
endSeq,
|
||||||
|
cursorKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { deliverySucceeded, visiblePhase } = await args.executeTurn({
|
||||||
|
group,
|
||||||
|
prompt,
|
||||||
|
chatJid,
|
||||||
|
runId,
|
||||||
|
channel: turnChannel,
|
||||||
|
deliveryRole: task ? turnRole : undefined,
|
||||||
|
startSeq,
|
||||||
|
endSeq,
|
||||||
|
hasHumanMessage: hasHumanMsg,
|
||||||
|
forcedRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deliverySucceeded) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
messageSeqStart: startSeq,
|
||||||
|
messageSeqEnd: endSeq,
|
||||||
|
},
|
||||||
|
'Persisted produced output for delivery retry without rerunning agent',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
visiblePhase,
|
||||||
|
messageSeqStart: startSeq,
|
||||||
|
messageSeqEnd: endSeq,
|
||||||
|
},
|
||||||
|
'Queued run completed successfully',
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
resolveFollowUpDispatch,
|
||||||
resolveExecutionTarget,
|
resolveExecutionTarget,
|
||||||
resolveNextTurnAction,
|
resolveNextTurnAction,
|
||||||
resolveQueuedTurnRole,
|
resolveQueuedTurnRole,
|
||||||
@@ -86,6 +87,72 @@ describe('message-runtime-rules', () => {
|
|||||||
).toEqual({ kind: 'none' });
|
).toEqual({ kind: 'none' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dispatches owner delivery success through a paired follow-up enqueue only for reviewer turns', () => {
|
||||||
|
expect(
|
||||||
|
resolveFollowUpDispatch({
|
||||||
|
source: 'owner-delivery-success',
|
||||||
|
nextTurnAction: { kind: 'reviewer-turn' },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: 'enqueue',
|
||||||
|
queueKind: 'paired-follow-up',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveFollowUpDispatch({
|
||||||
|
source: 'owner-delivery-success',
|
||||||
|
nextTurnAction: { kind: 'none' },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches delivery retry follow-ups through either generic message checks or paired follow-up enqueue', () => {
|
||||||
|
expect(
|
||||||
|
resolveFollowUpDispatch({
|
||||||
|
source: 'delivery-retry',
|
||||||
|
nextTurnAction: { kind: 'none' },
|
||||||
|
completedRole: 'owner',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: 'enqueue',
|
||||||
|
queueKind: 'message-check',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveFollowUpDispatch({
|
||||||
|
source: 'delivery-retry',
|
||||||
|
nextTurnAction: { kind: 'reviewer-turn' },
|
||||||
|
completedRole: 'owner',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: 'enqueue',
|
||||||
|
queueKind: 'paired-follow-up',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveFollowUpDispatch({
|
||||||
|
source: 'delivery-retry',
|
||||||
|
nextTurnAction: { kind: 'finalize-owner-turn' },
|
||||||
|
completedRole: 'reviewer',
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches bot-only follow-ups through inline finalize or paired enqueue', () => {
|
||||||
|
expect(
|
||||||
|
resolveFollowUpDispatch({
|
||||||
|
source: 'bot-only-follow-up',
|
||||||
|
nextTurnAction: { kind: 'finalize-owner-turn' },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'inline' });
|
||||||
|
expect(
|
||||||
|
resolveFollowUpDispatch({
|
||||||
|
source: 'bot-only-follow-up',
|
||||||
|
nextTurnAction: { kind: 'owner-follow-up' },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: 'enqueue',
|
||||||
|
queueKind: 'paired-follow-up',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('routes fresh human input to owner even while review is pending', () => {
|
it('routes fresh human input to owner even while review is pending', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveQueuedTurnRole({
|
resolveQueuedTurnRole({
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export type NextTurnAction =
|
|||||||
| { kind: 'owner-follow-up' }
|
| { kind: 'owner-follow-up' }
|
||||||
| { kind: 'finalize-owner-turn' };
|
| { kind: 'finalize-owner-turn' };
|
||||||
|
|
||||||
|
export type FollowUpDispatch =
|
||||||
|
| { kind: 'none' }
|
||||||
|
| { kind: 'inline' }
|
||||||
|
| { kind: 'enqueue'; queueKind: 'paired-follow-up' | 'message-check' };
|
||||||
|
|
||||||
export function resolveNextTurnAction(args: {
|
export function resolveNextTurnAction(args: {
|
||||||
taskStatus?: PairedTaskStatus | null;
|
taskStatus?: PairedTaskStatus | null;
|
||||||
lastTurnOutputRole?: PairedRoomRole | null;
|
lastTurnOutputRole?: PairedRoomRole | null;
|
||||||
@@ -108,6 +113,62 @@ export function resolveNextTurnAction(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveFollowUpDispatch(args: {
|
||||||
|
source:
|
||||||
|
| 'owner-delivery-success'
|
||||||
|
| 'delivery-retry'
|
||||||
|
| 'bot-only-follow-up'
|
||||||
|
| 'executor-recovery';
|
||||||
|
nextTurnAction: NextTurnAction;
|
||||||
|
completedRole?: PairedRoomRole;
|
||||||
|
executionStatus?: 'succeeded' | 'failed';
|
||||||
|
sawOutput?: boolean;
|
||||||
|
}): FollowUpDispatch {
|
||||||
|
switch (args.source) {
|
||||||
|
case 'owner-delivery-success':
|
||||||
|
return args.nextTurnAction.kind === 'reviewer-turn'
|
||||||
|
? { kind: 'enqueue', queueKind: 'paired-follow-up' }
|
||||||
|
: { kind: 'none' };
|
||||||
|
|
||||||
|
case 'delivery-retry':
|
||||||
|
if (
|
||||||
|
args.completedRole === 'reviewer' &&
|
||||||
|
args.nextTurnAction.kind === 'finalize-owner-turn'
|
||||||
|
) {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
if (args.nextTurnAction.kind === 'none') {
|
||||||
|
return { kind: 'enqueue', queueKind: 'message-check' };
|
||||||
|
}
|
||||||
|
return { kind: 'enqueue', queueKind: 'paired-follow-up' };
|
||||||
|
|
||||||
|
case 'bot-only-follow-up':
|
||||||
|
if (args.nextTurnAction.kind === 'none') {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
if (args.nextTurnAction.kind === 'finalize-owner-turn') {
|
||||||
|
return { kind: 'inline' };
|
||||||
|
}
|
||||||
|
return { kind: 'enqueue', queueKind: 'paired-follow-up' };
|
||||||
|
|
||||||
|
case 'executor-recovery':
|
||||||
|
if (args.executionStatus === 'succeeded' && args.sawOutput) {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
args.completedRole !== 'reviewer' &&
|
||||||
|
args.completedRole !== 'arbiter'
|
||||||
|
) {
|
||||||
|
return { kind: 'none' };
|
||||||
|
}
|
||||||
|
return args.nextTurnAction.kind === 'reviewer-turn' ||
|
||||||
|
args.nextTurnAction.kind === 'arbiter-turn' ||
|
||||||
|
args.nextTurnAction.kind === 'finalize-owner-turn'
|
||||||
|
? { kind: 'enqueue', queueKind: 'paired-follow-up' }
|
||||||
|
: { kind: 'none' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Cursor key for a role. Owner uses chatJid, others use chatJid:role. */
|
/** Cursor key for a role. Owner uses chatJid, others use chatJid:role. */
|
||||||
export function resolveCursorKey(
|
export function resolveCursorKey(
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
|
|||||||
40
src/message-runtime-shared.ts
Normal file
40
src/message-runtime-shared.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { PairedRoomRole } from './types.js';
|
||||||
|
import type { ServiceHandoff } from './db.js';
|
||||||
|
|
||||||
|
export function resolveHandoffRoleOverride(
|
||||||
|
handoff: Pick<ServiceHandoff, 'target_role' | 'intended_role' | 'reason'>,
|
||||||
|
): PairedRoomRole | undefined {
|
||||||
|
if (handoff.target_role) {
|
||||||
|
return handoff.target_role;
|
||||||
|
}
|
||||||
|
if (handoff.intended_role) {
|
||||||
|
return handoff.intended_role;
|
||||||
|
}
|
||||||
|
if (handoff.reason?.startsWith('reviewer-')) {
|
||||||
|
return 'reviewer';
|
||||||
|
}
|
||||||
|
if (handoff.reason?.startsWith('arbiter-')) {
|
||||||
|
return 'arbiter';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHandoffCursorKey(
|
||||||
|
chatJid: string,
|
||||||
|
role?: PairedRoomRole,
|
||||||
|
): string {
|
||||||
|
if (!role || role === 'owner') {
|
||||||
|
return chatJid;
|
||||||
|
}
|
||||||
|
return `${chatJid}:${role}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFixedRoleChannelName(role: 'reviewer' | 'arbiter'): string {
|
||||||
|
return role === 'reviewer' ? 'discord-review' : 'discord-arbiter';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMissingRoleChannelMessage(
|
||||||
|
role: 'reviewer' | 'arbiter',
|
||||||
|
): string {
|
||||||
|
return `Missing configured ${role} Discord bot channel (${getFixedRoleChannelName(role)}) for role-fixed delivery`;
|
||||||
|
}
|
||||||
@@ -219,6 +219,7 @@ import {
|
|||||||
} from './message-runtime-flow.js';
|
} from './message-runtime-flow.js';
|
||||||
import * as config from './config.js';
|
import * as config from './config.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js';
|
||||||
import * as serviceRouting from './service-routing.js';
|
import * as serviceRouting from './service-routing.js';
|
||||||
import type { Channel, RegisteredGroup } from './types.js';
|
import type { Channel, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
@@ -254,6 +255,7 @@ function makeChannel(
|
|||||||
describe('createMessageRuntime', () => {
|
describe('createMessageRuntime', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
resetPairedFollowUpScheduleState();
|
||||||
vi.mocked(db.getLastBotFinalMessage).mockReturnValue([]);
|
vi.mocked(db.getLastBotFinalMessage).mockReturnValue([]);
|
||||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
|
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
|
||||||
vi.mocked(db.getRecentChatMessages).mockReturnValue([]);
|
vi.mocked(db.getRecentChatMessages).mockReturnValue([]);
|
||||||
@@ -1697,6 +1699,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
task,
|
task,
|
||||||
cursor: 42,
|
cursor: 42,
|
||||||
cursorKey: `${chatJid}:reviewer`,
|
cursorKey: `${chatJid}:reviewer`,
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
nextRole: 'reviewer',
|
nextRole: 'reviewer',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1806,6 +1809,115 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not enqueue the same reviewer follow-up twice within the same run', async () => {
|
||||||
|
const chatJid = 'group@test';
|
||||||
|
const group = makeGroup('codex');
|
||||||
|
const ownerChannel: Channel = {
|
||||||
|
...makeChannel(chatJid),
|
||||||
|
isOwnMessage: vi.fn((msg) => msg.sender === 'owner-bot@test'),
|
||||||
|
};
|
||||||
|
const reviewerChannel = makeChannel(chatJid, 'discord-review', false);
|
||||||
|
const enqueueMessageCheck = vi.fn();
|
||||||
|
const pairedTask = {
|
||||||
|
id: 'task-owner-delivery-dedup',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockImplementation(
|
||||||
|
() => pairedTask,
|
||||||
|
);
|
||||||
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'human-dedup-1',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'user@test',
|
||||||
|
sender_name: 'User',
|
||||||
|
content: '이 구현 진행해줘',
|
||||||
|
timestamp: '2026-03-30T00:00:00.000Z',
|
||||||
|
seq: 1,
|
||||||
|
is_bot_message: false,
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
pairedTask.status = 'review_ready';
|
||||||
|
pairedTask.review_requested_at = '2026-03-30T00:00:01.000Z';
|
||||||
|
pairedTask.round_trip_count = 1;
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'final',
|
||||||
|
result: 'DONE_WITH_CONCERNS\nowner complete',
|
||||||
|
newSessionId: 'session-owner-delivery-dedup',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
result: 'DONE_WITH_CONCERNS\nowner complete',
|
||||||
|
newSessionId: 'session-owner-delivery-dedup',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtime = createMessageRuntime({
|
||||||
|
assistantName: 'Andy',
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
pollInterval: 1_000,
|
||||||
|
timezone: 'UTC',
|
||||||
|
triggerPattern: /^@Andy\b/i,
|
||||||
|
channels: [ownerChannel, reviewerChannel],
|
||||||
|
queue: {
|
||||||
|
registerProcess: vi.fn(),
|
||||||
|
closeStdin: vi.fn(),
|
||||||
|
notifyIdle: vi.fn(),
|
||||||
|
enqueueMessageCheck,
|
||||||
|
} as any,
|
||||||
|
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||||
|
getSessions: () => ({}),
|
||||||
|
getLastTimestamp: () => '',
|
||||||
|
setLastTimestamp: vi.fn(),
|
||||||
|
getLastAgentTimestamps: () => ({}),
|
||||||
|
saveState: vi.fn(),
|
||||||
|
persistSession: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.processGroupMessages(chatJid, {
|
||||||
|
runId: 'run-owner-delivery-dedup',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
await runtime.processGroupMessages(chatJid, {
|
||||||
|
runId: 'run-owner-delivery-dedup',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enqueueMessageCheck).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatJid,
|
||||||
|
runId: 'run-owner-delivery-dedup',
|
||||||
|
completedRole: 'owner',
|
||||||
|
taskId: 'task-owner-delivery-dedup',
|
||||||
|
taskStatus: 'review_ready',
|
||||||
|
scheduled: false,
|
||||||
|
}),
|
||||||
|
'Skipped duplicate paired follow-up after successful owner delivery in the same run',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('routes fresh human input to owner even when the latest task is review_ready', async () => {
|
it('routes fresh human input to owner even when the latest task is review_ready', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,10 @@ import { logger } from './logger.js';
|
|||||||
import { markPairedTaskReviewReady } from './paired-workspace-manager.js';
|
import { markPairedTaskReviewReady } from './paired-workspace-manager.js';
|
||||||
import {
|
import {
|
||||||
applyPairedTaskPatch,
|
applyPairedTaskPatch,
|
||||||
classifyVerdict,
|
|
||||||
hasCodeChangesSinceRef,
|
hasCodeChangesSinceRef,
|
||||||
|
parseVisibleVerdict,
|
||||||
requestArbiterOrEscalate,
|
requestArbiterOrEscalate,
|
||||||
|
resolveOwnerCompletionSignal,
|
||||||
resolveCanonicalSourceRef,
|
resolveCanonicalSourceRef,
|
||||||
transitionPairedTaskStatus,
|
transitionPairedTaskStatus,
|
||||||
} from './paired-execution-context-shared.js';
|
} from './paired-execution-context-shared.js';
|
||||||
@@ -44,93 +45,68 @@ function handleOwnerFinalizeCompletion(args: {
|
|||||||
now: string;
|
now: string;
|
||||||
}): OwnerFinalizeOutcome {
|
}): OwnerFinalizeOutcome {
|
||||||
const { task, taskId, summary, now } = args;
|
const { task, taskId, summary, now } = args;
|
||||||
const ownerVerdict = classifyVerdict(summary);
|
const ownerVerdict = parseVisibleVerdict(summary);
|
||||||
|
const workspace = getPairedWorkspace(task.id, 'owner');
|
||||||
|
const hasNewChanges = workspace?.workspace_dir
|
||||||
|
? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref)
|
||||||
|
: null;
|
||||||
|
const signal = resolveOwnerCompletionSignal({
|
||||||
|
phase: 'finalize',
|
||||||
|
visibleVerdict: ownerVerdict,
|
||||||
|
hasChangesSinceApproval: hasNewChanges,
|
||||||
|
roundTripCount: task.round_trip_count,
|
||||||
|
deadlockThreshold: ARBITER_DEADLOCK_THRESHOLD,
|
||||||
|
});
|
||||||
|
|
||||||
if (ownerVerdict === 'blocked' || ownerVerdict === 'needs_context') {
|
if (signal.kind === 'request_arbiter') {
|
||||||
|
const arbiterLogMessage =
|
||||||
|
ownerVerdict === 'blocked' || ownerVerdict === 'needs_context'
|
||||||
|
? 'Owner blocked during finalize — requesting arbiter'
|
||||||
|
: ownerVerdict === 'done_with_concerns'
|
||||||
|
? 'Owner finalize loop detected — requesting arbiter'
|
||||||
|
: 'Owner finalize DONE loop detected — requesting arbiter';
|
||||||
|
const escalateLogMessage =
|
||||||
|
ownerVerdict === 'blocked' || ownerVerdict === 'needs_context'
|
||||||
|
? 'Owner blocked during finalize — escalating to user'
|
||||||
|
: ownerVerdict === 'done_with_concerns'
|
||||||
|
? 'Owner finalize loop detected — escalating to user'
|
||||||
|
: 'Owner finalize DONE loop detected — escalating to user';
|
||||||
requestArbiterOrEscalate({
|
requestArbiterOrEscalate({
|
||||||
taskId,
|
taskId,
|
||||||
currentStatus: task.status,
|
currentStatus: task.status,
|
||||||
now,
|
now,
|
||||||
arbiterLogMessage: 'Owner blocked during finalize — requesting arbiter',
|
arbiterLogMessage,
|
||||||
escalateLogMessage: 'Owner blocked during finalize — escalating to user',
|
escalateLogMessage,
|
||||||
logContext: {
|
logContext: {
|
||||||
taskId,
|
taskId,
|
||||||
ownerVerdict,
|
ownerVerdict,
|
||||||
|
roundTrips: task.round_trip_count,
|
||||||
|
hasNewChanges,
|
||||||
summary: summary?.slice(0, 100),
|
summary: summary?.slice(0, 100),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return 'stop';
|
return 'stop';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ownerVerdict === 'done_with_concerns') {
|
if (signal.kind === 'request_reviewer') {
|
||||||
if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
|
if (signal.resetStatusToActive) {
|
||||||
requestArbiterOrEscalate({
|
transitionPairedTaskStatus({
|
||||||
taskId,
|
taskId,
|
||||||
currentStatus: task.status,
|
currentStatus: task.status,
|
||||||
now,
|
nextStatus: 'active',
|
||||||
arbiterLogMessage: 'Owner finalize loop detected — requesting arbiter',
|
updatedAt: now,
|
||||||
escalateLogMessage: 'Owner finalize loop detected — escalating to user',
|
|
||||||
logContext: {
|
|
||||||
taskId,
|
|
||||||
ownerVerdict,
|
|
||||||
roundTrips: task.round_trip_count,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return 'stop';
|
|
||||||
}
|
}
|
||||||
transitionPairedTaskStatus({
|
|
||||||
taskId,
|
|
||||||
currentStatus: task.status,
|
|
||||||
nextStatus: 'active',
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
taskId,
|
taskId,
|
||||||
ownerVerdict,
|
ownerVerdict,
|
||||||
|
hasNewChanges,
|
||||||
summary: summary?.slice(0, 100),
|
summary: summary?.slice(0, 100),
|
||||||
},
|
},
|
||||||
'Owner raised concerns during finalize — task set back to active',
|
ownerVerdict === 'done_with_concerns'
|
||||||
);
|
? 'Owner raised concerns during finalize — task set back to active'
|
||||||
return 're_review';
|
: 'Owner made changes after reviewer approval — task set back to active before re-review',
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = getPairedWorkspace(task.id, 'owner');
|
|
||||||
const hasNewChanges = workspace?.workspace_dir
|
|
||||||
? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (hasNewChanges === true) {
|
|
||||||
if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
|
|
||||||
requestArbiterOrEscalate({
|
|
||||||
taskId,
|
|
||||||
currentStatus: task.status,
|
|
||||||
now,
|
|
||||||
arbiterLogMessage:
|
|
||||||
'Owner finalize DONE loop detected — requesting arbiter',
|
|
||||||
escalateLogMessage:
|
|
||||||
'Owner finalize DONE loop detected — escalating to user',
|
|
||||||
logContext: {
|
|
||||||
taskId,
|
|
||||||
roundTrips: task.round_trip_count,
|
|
||||||
hasNewChanges,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return 'stop';
|
|
||||||
}
|
|
||||||
transitionPairedTaskStatus({
|
|
||||||
taskId,
|
|
||||||
currentStatus: task.status,
|
|
||||||
nextStatus: 'active',
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
taskId,
|
|
||||||
sourceRef: task.source_ref,
|
|
||||||
hasNewChanges,
|
|
||||||
},
|
|
||||||
'Owner made changes after reviewer approval — task set back to active before re-review',
|
|
||||||
);
|
);
|
||||||
return 're_review';
|
return 're_review';
|
||||||
}
|
}
|
||||||
@@ -219,8 +195,13 @@ export function handleOwnerCompletion(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerVerdict = classifyVerdict(summary);
|
const ownerVerdict = parseVisibleVerdict(summary);
|
||||||
if (ownerVerdict === 'blocked' || ownerVerdict === 'needs_context') {
|
const signal = resolveOwnerCompletionSignal({
|
||||||
|
phase: 'normal',
|
||||||
|
visibleVerdict: ownerVerdict,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal.kind === 'request_arbiter') {
|
||||||
requestArbiterOrEscalate({
|
requestArbiterOrEscalate({
|
||||||
taskId,
|
taskId,
|
||||||
currentStatus: task.status,
|
currentStatus: task.status,
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { ARBITER_DEADLOCK_THRESHOLD } from './config.js';
|
|||||||
import { getPairedWorkspace, updatePairedTask } from './db.js';
|
import { getPairedWorkspace, updatePairedTask } from './db.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
classifyVerdict,
|
parseVisibleVerdict,
|
||||||
requestArbiterOrEscalate,
|
requestArbiterOrEscalate,
|
||||||
|
resolveReviewerCompletionSignal,
|
||||||
|
resolveReviewerFailureSignal,
|
||||||
resolveCanonicalSourceRef,
|
resolveCanonicalSourceRef,
|
||||||
transitionPairedTaskStatus,
|
transitionPairedTaskStatus,
|
||||||
} from './paired-execution-context-shared.js';
|
} from './paired-execution-context-shared.js';
|
||||||
@@ -18,26 +20,37 @@ export function handleFailedReviewerExecution(args: {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
if (summary) {
|
if (summary) {
|
||||||
const verdict = classifyVerdict(summary);
|
const verdict = parseVisibleVerdict(summary);
|
||||||
|
const signal = resolveReviewerFailureSignal({
|
||||||
|
visibleVerdict: verdict,
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
verdict === 'done' ||
|
signal.kind === 'request_owner_finalize' ||
|
||||||
verdict === 'blocked' ||
|
signal.kind === 'complete'
|
||||||
verdict === 'needs_context'
|
|
||||||
) {
|
) {
|
||||||
const ownerWs =
|
const ownerWs =
|
||||||
verdict === 'done' ? getPairedWorkspace(taskId, 'owner') : null;
|
signal.kind === 'request_owner_finalize'
|
||||||
|
? getPairedWorkspace(taskId, 'owner')
|
||||||
|
: null;
|
||||||
const approvedSourceRef =
|
const approvedSourceRef =
|
||||||
verdict === 'done' && ownerWs?.workspace_dir
|
signal.kind === 'request_owner_finalize' && ownerWs?.workspace_dir
|
||||||
? resolveCanonicalSourceRef(ownerWs.workspace_dir)
|
? resolveCanonicalSourceRef(ownerWs.workspace_dir)
|
||||||
: task.source_ref;
|
: task.source_ref;
|
||||||
transitionPairedTaskStatus({
|
transitionPairedTaskStatus({
|
||||||
taskId,
|
taskId,
|
||||||
currentStatus: task.status,
|
currentStatus: task.status,
|
||||||
nextStatus: verdict === 'done' ? 'merge_ready' : 'completed',
|
nextStatus:
|
||||||
|
signal.kind === 'request_owner_finalize'
|
||||||
|
? 'merge_ready'
|
||||||
|
: 'completed',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
patch: {
|
patch: {
|
||||||
...(verdict === 'done' ? { source_ref: approvedSourceRef } : {}),
|
...(signal.kind === 'request_owner_finalize'
|
||||||
...(verdict !== 'done' ? { completion_reason: 'escalated' } : {}),
|
? { source_ref: approvedSourceRef }
|
||||||
|
: {}),
|
||||||
|
...(signal.kind === 'complete'
|
||||||
|
? { completion_reason: signal.completionReason }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -83,10 +96,15 @@ export function handleReviewerCompletion(args: {
|
|||||||
}): void {
|
}): void {
|
||||||
const { task, taskId, summary } = args;
|
const { task, taskId, summary } = args;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const verdict = classifyVerdict(summary);
|
const verdict = parseVisibleVerdict(summary);
|
||||||
|
const signal = resolveReviewerCompletionSignal({
|
||||||
|
visibleVerdict: verdict,
|
||||||
|
roundTripCount: task.round_trip_count,
|
||||||
|
deadlockThreshold: ARBITER_DEADLOCK_THRESHOLD,
|
||||||
|
});
|
||||||
|
|
||||||
switch (verdict) {
|
switch (signal.kind) {
|
||||||
case 'done': {
|
case 'request_owner_finalize': {
|
||||||
const ownerWs = getPairedWorkspace(taskId, 'owner');
|
const ownerWs = getPairedWorkspace(taskId, 'owner');
|
||||||
const approvedSourceRef = ownerWs?.workspace_dir
|
const approvedSourceRef = ownerWs?.workspace_dir
|
||||||
? resolveCanonicalSourceRef(ownerWs.workspace_dir)
|
? resolveCanonicalSourceRef(ownerWs.workspace_dir)
|
||||||
@@ -112,39 +130,26 @@ export function handleReviewerCompletion(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'blocked':
|
case 'request_arbiter':
|
||||||
case 'needs_context':
|
const arbiterLogMessage =
|
||||||
|
verdict === 'blocked' || verdict === 'needs_context'
|
||||||
|
? 'Reviewer blocked/needs_context — requesting arbiter before escalating'
|
||||||
|
: 'Deadlock detected — requesting arbiter intervention';
|
||||||
|
const escalateLogMessage =
|
||||||
|
verdict === 'blocked' || verdict === 'needs_context'
|
||||||
|
? 'Reviewer escalated to user — ping-pong stopped'
|
||||||
|
: 'Stopped ping-pong — escalating to user (arbiter not configured)';
|
||||||
requestArbiterOrEscalate({
|
requestArbiterOrEscalate({
|
||||||
taskId,
|
taskId,
|
||||||
currentStatus: task.status,
|
currentStatus: task.status,
|
||||||
now,
|
now,
|
||||||
arbiterLogMessage:
|
arbiterLogMessage,
|
||||||
'Reviewer blocked/needs_context — requesting arbiter before escalating',
|
escalateLogMessage,
|
||||||
escalateLogMessage: 'Reviewer escalated to user — ping-pong stopped',
|
|
||||||
logContext: { taskId, verdict, summary: summary?.slice(0, 100) },
|
logContext: { taskId, verdict, summary: summary?.slice(0, 100) },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'done_with_concerns':
|
case 'request_owner_changes':
|
||||||
case 'continue':
|
|
||||||
default:
|
|
||||||
if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
|
|
||||||
requestArbiterOrEscalate({
|
|
||||||
taskId,
|
|
||||||
currentStatus: task.status,
|
|
||||||
now,
|
|
||||||
arbiterLogMessage:
|
|
||||||
'Deadlock detected — requesting arbiter intervention',
|
|
||||||
escalateLogMessage:
|
|
||||||
'Stopped ping-pong — escalating to user (arbiter not configured)',
|
|
||||||
logContext: {
|
|
||||||
taskId,
|
|
||||||
verdict,
|
|
||||||
roundTrips: task.round_trip_count,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
transitionPairedTaskStatus({
|
transitionPairedTaskStatus({
|
||||||
taskId,
|
taskId,
|
||||||
currentStatus: task.status,
|
currentStatus: task.status,
|
||||||
@@ -156,5 +161,7 @@ export function handleReviewerCompletion(args: {
|
|||||||
'Reviewer has feedback, task set back to active for owner',
|
'Reviewer has feedback, task set back to active for owner',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
src/paired-execution-context-shared.test.ts
Normal file
113
src/paired-execution-context-shared.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseVisibleVerdict,
|
||||||
|
resolveOwnerCompletionSignal,
|
||||||
|
resolveReviewerCompletionSignal,
|
||||||
|
resolveReviewerFailureSignal,
|
||||||
|
} from './paired-execution-context-shared.js';
|
||||||
|
|
||||||
|
describe('paired execution context shared verdict helpers', () => {
|
||||||
|
it('parses visible verdicts from the first summary line only', () => {
|
||||||
|
expect(
|
||||||
|
parseVisibleVerdict(
|
||||||
|
'DONE_WITH_CONCERNS\n\nfollow-up detail that should not affect parsing',
|
||||||
|
),
|
||||||
|
).toBe('done_with_concerns');
|
||||||
|
expect(parseVisibleVerdict('BLOCKED\nextra detail')).toBe('blocked');
|
||||||
|
expect(parseVisibleVerdict('random prose')).toBe('continue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps normal owner completion verdicts to reviewer or arbiter signals', () => {
|
||||||
|
expect(
|
||||||
|
resolveOwnerCompletionSignal({
|
||||||
|
phase: 'normal',
|
||||||
|
visibleVerdict: 'done',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: 'request_reviewer',
|
||||||
|
resetStatusToActive: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveOwnerCompletionSignal({
|
||||||
|
phase: 'normal',
|
||||||
|
visibleVerdict: 'blocked',
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'request_arbiter' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps finalize owner outcomes to complete, re-review, or arbiter', () => {
|
||||||
|
expect(
|
||||||
|
resolveOwnerCompletionSignal({
|
||||||
|
phase: 'finalize',
|
||||||
|
visibleVerdict: 'done',
|
||||||
|
hasChangesSinceApproval: false,
|
||||||
|
roundTripCount: 0,
|
||||||
|
deadlockThreshold: 2,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'complete', completionReason: 'done' });
|
||||||
|
expect(
|
||||||
|
resolveOwnerCompletionSignal({
|
||||||
|
phase: 'finalize',
|
||||||
|
visibleVerdict: 'done_with_concerns',
|
||||||
|
hasChangesSinceApproval: false,
|
||||||
|
roundTripCount: 1,
|
||||||
|
deadlockThreshold: 3,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: 'request_reviewer',
|
||||||
|
resetStatusToActive: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveOwnerCompletionSignal({
|
||||||
|
phase: 'finalize',
|
||||||
|
visibleVerdict: 'done',
|
||||||
|
hasChangesSinceApproval: true,
|
||||||
|
roundTripCount: 3,
|
||||||
|
deadlockThreshold: 3,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'request_arbiter' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps reviewer completion verdicts to finalize, owner changes, or arbiter', () => {
|
||||||
|
expect(
|
||||||
|
resolveReviewerCompletionSignal({
|
||||||
|
visibleVerdict: 'done',
|
||||||
|
roundTripCount: 0,
|
||||||
|
deadlockThreshold: 3,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'request_owner_finalize' });
|
||||||
|
expect(
|
||||||
|
resolveReviewerCompletionSignal({
|
||||||
|
visibleVerdict: 'continue',
|
||||||
|
roundTripCount: 1,
|
||||||
|
deadlockThreshold: 3,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'request_owner_changes' });
|
||||||
|
expect(
|
||||||
|
resolveReviewerCompletionSignal({
|
||||||
|
visibleVerdict: 'done_with_concerns',
|
||||||
|
roundTripCount: 3,
|
||||||
|
deadlockThreshold: 3,
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'request_arbiter' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps reviewer failure verdicts to explicit failure signals', () => {
|
||||||
|
expect(
|
||||||
|
resolveReviewerFailureSignal({
|
||||||
|
visibleVerdict: 'done',
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'request_owner_finalize' });
|
||||||
|
expect(
|
||||||
|
resolveReviewerFailureSignal({
|
||||||
|
visibleVerdict: 'needs_context',
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'complete', completionReason: 'escalated' });
|
||||||
|
expect(
|
||||||
|
resolveReviewerFailureSignal({
|
||||||
|
visibleVerdict: 'continue',
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: 'preserve_review_ready' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,14 +5,24 @@ import { updatePairedTask } from './db.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { PairedTaskStatus } from './types.js';
|
import type { PairedTaskStatus } from './types.js';
|
||||||
|
|
||||||
export type Verdict =
|
export type VisibleVerdict =
|
||||||
| 'done'
|
| 'done'
|
||||||
| 'done_with_concerns'
|
| 'done_with_concerns'
|
||||||
| 'blocked'
|
| 'blocked'
|
||||||
| 'needs_context'
|
| 'needs_context'
|
||||||
| 'continue';
|
| 'continue';
|
||||||
|
|
||||||
export function classifyVerdict(summary: string | null | undefined): Verdict {
|
export type CompletionSignal =
|
||||||
|
| { kind: 'request_reviewer'; resetStatusToActive: boolean }
|
||||||
|
| { kind: 'request_owner_finalize' }
|
||||||
|
| { kind: 'request_owner_changes' }
|
||||||
|
| { kind: 'request_arbiter' }
|
||||||
|
| { kind: 'complete'; completionReason: 'done' | 'escalated' }
|
||||||
|
| { kind: 'preserve_review_ready' };
|
||||||
|
|
||||||
|
export function parseVisibleVerdict(
|
||||||
|
summary: string | null | undefined,
|
||||||
|
): VisibleVerdict {
|
||||||
if (!summary) return 'continue';
|
if (!summary) return 'continue';
|
||||||
const cleaned = summary.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
const cleaned = summary.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||||
if (!cleaned) return 'continue';
|
if (!cleaned) return 'continue';
|
||||||
@@ -27,6 +37,95 @@ export function classifyVerdict(summary: string | null | undefined): Verdict {
|
|||||||
return 'continue';
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveOwnerCompletionSignal(args: {
|
||||||
|
phase: 'normal' | 'finalize';
|
||||||
|
visibleVerdict: VisibleVerdict;
|
||||||
|
hasChangesSinceApproval?: boolean | null;
|
||||||
|
roundTripCount?: number;
|
||||||
|
deadlockThreshold?: number;
|
||||||
|
}): CompletionSignal {
|
||||||
|
const {
|
||||||
|
phase,
|
||||||
|
visibleVerdict,
|
||||||
|
hasChangesSinceApproval = false,
|
||||||
|
roundTripCount = 0,
|
||||||
|
deadlockThreshold = Number.POSITIVE_INFINITY,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
if (visibleVerdict === 'blocked' || visibleVerdict === 'needs_context') {
|
||||||
|
return { kind: 'request_arbiter' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'normal') {
|
||||||
|
return {
|
||||||
|
kind: 'request_reviewer',
|
||||||
|
resetStatusToActive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsReReview =
|
||||||
|
visibleVerdict === 'done_with_concerns' || hasChangesSinceApproval === true;
|
||||||
|
|
||||||
|
if (needsReReview) {
|
||||||
|
if (roundTripCount >= deadlockThreshold) {
|
||||||
|
return { kind: 'request_arbiter' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'request_reviewer',
|
||||||
|
resetStatusToActive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'complete',
|
||||||
|
completionReason: 'done',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveReviewerCompletionSignal(args: {
|
||||||
|
visibleVerdict: VisibleVerdict;
|
||||||
|
roundTripCount: number;
|
||||||
|
deadlockThreshold: number;
|
||||||
|
}): CompletionSignal {
|
||||||
|
const { visibleVerdict, roundTripCount, deadlockThreshold } = args;
|
||||||
|
|
||||||
|
switch (visibleVerdict) {
|
||||||
|
case 'done':
|
||||||
|
return { kind: 'request_owner_finalize' };
|
||||||
|
case 'blocked':
|
||||||
|
case 'needs_context':
|
||||||
|
return { kind: 'request_arbiter' };
|
||||||
|
case 'done_with_concerns':
|
||||||
|
case 'continue':
|
||||||
|
default:
|
||||||
|
if (roundTripCount >= deadlockThreshold) {
|
||||||
|
return { kind: 'request_arbiter' };
|
||||||
|
}
|
||||||
|
return { kind: 'request_owner_changes' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveReviewerFailureSignal(args: {
|
||||||
|
visibleVerdict: VisibleVerdict;
|
||||||
|
}): CompletionSignal {
|
||||||
|
const { visibleVerdict } = args;
|
||||||
|
|
||||||
|
switch (visibleVerdict) {
|
||||||
|
case 'done':
|
||||||
|
return { kind: 'request_owner_finalize' };
|
||||||
|
case 'blocked':
|
||||||
|
case 'needs_context':
|
||||||
|
return {
|
||||||
|
kind: 'complete',
|
||||||
|
completionReason: 'escalated',
|
||||||
|
};
|
||||||
|
case 'done_with_concerns':
|
||||||
|
case 'continue':
|
||||||
|
default:
|
||||||
|
return { kind: 'preserve_review_ready' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ArbiterVerdictResult =
|
export type ArbiterVerdictResult =
|
||||||
| 'proceed'
|
| 'proceed'
|
||||||
| 'revise'
|
| 'revise'
|
||||||
|
|||||||
119
src/paired-follow-up-scheduler.test.ts
Normal file
119
src/paired-follow-up-scheduler.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SCHEDULED_PAIRED_FOLLOW_UP_TTL_MS,
|
||||||
|
buildPairedFollowUpKey,
|
||||||
|
resetPairedFollowUpScheduleState,
|
||||||
|
schedulePairedFollowUpOnce,
|
||||||
|
} from './paired-follow-up-scheduler.js';
|
||||||
|
|
||||||
|
describe('paired follow-up scheduler', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetPairedFollowUpScheduleState();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates the same follow-up intent within one run', () => {
|
||||||
|
const enqueue = vi.fn();
|
||||||
|
const task = {
|
||||||
|
id: 'task-1',
|
||||||
|
status: 'review_ready',
|
||||||
|
round_trip_count: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const first = schedulePairedFollowUpOnce({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-1',
|
||||||
|
task,
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
enqueue,
|
||||||
|
});
|
||||||
|
const second = schedulePairedFollowUpOnce({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-1',
|
||||||
|
task,
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
enqueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toBe(true);
|
||||||
|
expect(second).toBe(false);
|
||||||
|
expect(enqueue).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps different round trips schedulable', () => {
|
||||||
|
const enqueue = vi.fn();
|
||||||
|
|
||||||
|
const first = schedulePairedFollowUpOnce({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-1',
|
||||||
|
task: {
|
||||||
|
id: 'task-1',
|
||||||
|
status: 'review_ready',
|
||||||
|
round_trip_count: 1,
|
||||||
|
} as const,
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
enqueue,
|
||||||
|
});
|
||||||
|
const second = schedulePairedFollowUpOnce({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-1',
|
||||||
|
task: {
|
||||||
|
id: 'task-1',
|
||||||
|
status: 'review_ready',
|
||||||
|
round_trip_count: 2,
|
||||||
|
} as const,
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
enqueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toBe(true);
|
||||||
|
expect(second).toBe(true);
|
||||||
|
expect(enqueue).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a key that includes round trip count and intent', () => {
|
||||||
|
expect(
|
||||||
|
buildPairedFollowUpKey({
|
||||||
|
taskId: 'task-1',
|
||||||
|
taskStatus: 'review_ready',
|
||||||
|
roundTripCount: 3,
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
}),
|
||||||
|
).toBe('task-1:review_ready:3:reviewer-turn');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same follow-up again after the TTL expires', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-03-30T00:00:00.000Z'));
|
||||||
|
|
||||||
|
const enqueue = vi.fn();
|
||||||
|
const task = {
|
||||||
|
id: 'task-1',
|
||||||
|
status: 'review_ready',
|
||||||
|
round_trip_count: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const first = schedulePairedFollowUpOnce({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-1',
|
||||||
|
task,
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
enqueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(SCHEDULED_PAIRED_FOLLOW_UP_TTL_MS + 1);
|
||||||
|
|
||||||
|
const second = schedulePairedFollowUpOnce({
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-1',
|
||||||
|
task,
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
enqueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toBe(true);
|
||||||
|
expect(second).toBe(true);
|
||||||
|
expect(enqueue).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/paired-follow-up-scheduler.ts
Normal file
71
src/paired-follow-up-scheduler.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { PairedTask, PairedTaskStatus } from './types.js';
|
||||||
|
|
||||||
|
export type ScheduledPairedFollowUpIntentKind =
|
||||||
|
| 'reviewer-turn'
|
||||||
|
| 'arbiter-turn'
|
||||||
|
| 'owner-follow-up'
|
||||||
|
| 'finalize-owner-turn';
|
||||||
|
|
||||||
|
type ScheduledPairedFollowUpTask = Pick<
|
||||||
|
PairedTask,
|
||||||
|
'id' | 'status' | 'round_trip_count'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const SCHEDULED_PAIRED_FOLLOW_UP_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const scheduledPairedFollowUps = new Map<string, number>();
|
||||||
|
|
||||||
|
function pruneExpiredScheduledPairedFollowUps(now: number): void {
|
||||||
|
for (const [key, scheduledAt] of scheduledPairedFollowUps) {
|
||||||
|
if (now - scheduledAt > SCHEDULED_PAIRED_FOLLOW_UP_TTL_MS) {
|
||||||
|
scheduledPairedFollowUps.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPairedFollowUpKey(args: {
|
||||||
|
taskId: string;
|
||||||
|
taskStatus: PairedTaskStatus | null;
|
||||||
|
roundTripCount: number;
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind;
|
||||||
|
}): string {
|
||||||
|
return [
|
||||||
|
args.taskId,
|
||||||
|
args.taskStatus ?? 'unknown',
|
||||||
|
String(args.roundTripCount),
|
||||||
|
args.intentKind,
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schedulePairedFollowUpOnce(args: {
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
task: ScheduledPairedFollowUpTask;
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind;
|
||||||
|
enqueue: () => void;
|
||||||
|
}): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
pruneExpiredScheduledPairedFollowUps(now);
|
||||||
|
|
||||||
|
const key = [
|
||||||
|
args.chatJid,
|
||||||
|
args.runId,
|
||||||
|
buildPairedFollowUpKey({
|
||||||
|
taskId: args.task.id,
|
||||||
|
taskStatus: args.task.status,
|
||||||
|
roundTripCount: args.task.round_trip_count,
|
||||||
|
intentKind: args.intentKind,
|
||||||
|
}),
|
||||||
|
].join(':');
|
||||||
|
|
||||||
|
if (scheduledPairedFollowUps.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledPairedFollowUps.set(key, now);
|
||||||
|
args.enqueue();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPairedFollowUpScheduleState(): void {
|
||||||
|
scheduledPairedFollowUps.clear();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user