Refactor paired runtime orchestration

This commit is contained in:
ejclaw
2026-04-07 23:45:53 +09:00
parent 8f08a79369
commit f9d9c2af6d
23 changed files with 2770 additions and 1192 deletions

View 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',
);
},
};
}

View File

@@ -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';
} }

View File

@@ -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);

View File

@@ -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',
);
}
}
} }
} }

View 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';
}

View 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,
});
}

View 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',
);
});
});

View File

@@ -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'
);
}

View 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 };
}

View 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
View 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));
};
}

View 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;
}

View File

@@ -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({

View File

@@ -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,

View 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`;
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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;
} }
} }

View 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' });
});
});

View File

@@ -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'

View 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);
});
});

View 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();
}