Merge pull request #165 from phj1081/codex/owner/ejclaw

fix: handle paired preemption and task scope prompts
This commit is contained in:
Eyejoker
2026-05-26 03:28:42 +09:00
committed by GitHub
14 changed files with 484 additions and 67 deletions

View File

@@ -30,6 +30,7 @@ export interface GroupState {
runPhase: RunPhase; runPhase: RunPhase;
runningTaskId: string | null; runningTaskId: string | null;
currentRunId: string | null; currentRunId: string | null;
lastCloseRequest: { runId: string | null; reason: string | null } | null;
directTerminalDeliveries: Map<string, string>; directTerminalDeliveries: Map<string, string>;
recentDirectTerminalDeliveries: Map<string, Map<string, string>>; recentDirectTerminalDeliveries: Map<string, Map<string, string>>;
pendingMessages: boolean; pendingMessages: boolean;
@@ -52,6 +53,7 @@ export function createGroupState(): GroupState {
runPhase: 'idle', runPhase: 'idle',
runningTaskId: null, runningTaskId: null,
currentRunId: null, currentRunId: null,
lastCloseRequest: null,
directTerminalDeliveries: new Map(), directTerminalDeliveries: new Map(),
recentDirectTerminalDeliveries: new Map(), recentDirectTerminalDeliveries: new Map(),
pendingMessages: false, pendingMessages: false,
@@ -139,6 +141,7 @@ export function transitionRunPhase(
export function resetRunState(state: GroupState, groupJid: string): void { export function resetRunState(state: GroupState, groupJid: string): void {
state.currentRunId = null; state.currentRunId = null;
state.runningTaskId = null; state.runningTaskId = null;
state.lastCloseRequest = null;
state.startedAt = null; state.startedAt = null;
state.process = null; state.process = null;
state.processName = null; state.processName = null;

View File

@@ -747,3 +747,47 @@ describe('GroupQueue', () => {
await vi.advanceTimersByTimeAsync(10); await vi.advanceTimersByTimeAsync(10);
}); });
}); });
describe('GroupQueue close reason tracking', () => {
let queue: GroupQueue;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
queue = new GroupQueue();
});
afterEach(() => {
vi.useRealTimers();
});
it('records the close reason for the active message run until it finishes', async () => {
const ipcDir = '/tmp/ejclaw-test-data/ipc/group-folder';
let releaseRun!: (value: boolean) => void;
let activeRunId = '';
const blocker = new Promise<boolean>((resolve) => {
releaseRun = resolve;
});
queue.setProcessMessagesFn(
vi.fn(async (_groupJid: string, context: GroupRunContext) => {
activeRunId = context.runId;
return await blocker;
}),
);
queue.enqueueMessageCheck('group1@g.us', ipcDir);
await vi.advanceTimersByTimeAsync(10);
queue.closeStdin('group1@g.us', { reason: 'human-message-detected' });
expect(queue.getCloseReasonForRun('group1@g.us', activeRunId)).toBe(
'human-message-detected',
);
expect(queue.getCloseReasonForRun('group1@g.us', 'other-run')).toBeNull();
releaseRun(true);
await vi.advanceTimersByTimeAsync(10);
expect(queue.getCloseReasonForRun('group1@g.us', activeRunId)).toBeNull();
});
});

View File

@@ -322,7 +322,10 @@ export class GroupQueue {
} }
return state.directTerminalDeliveries.get(senderRole) ?? null; return state.directTerminalDeliveries.get(senderRole) ?? null;
} }
getCloseReasonForRun(groupJid: string, runId: string): string | null {
const closeRequest = this.getGroup(groupJid).lastCloseRequest;
return closeRequest?.runId === runId ? closeRequest.reason : null;
}
hasRecordedDirectTerminalDeliveryForRun( hasRecordedDirectTerminalDeliveryForRun(
groupJid: string, groupJid: string,
runId: string, runId: string,
@@ -342,7 +345,6 @@ export class GroupQueue {
state.recentDirectTerminalDeliveries.get(runId)?.has(senderRole) ?? false state.recentDirectTerminalDeliveries.get(runId)?.has(senderRole) ?? false
); );
} }
private clearPostCloseTimers(state: GroupState): void { private clearPostCloseTimers(state: GroupState): void {
if (state.postCloseTermTimer) { if (state.postCloseTermTimer) {
clearTimeout(state.postCloseTermTimer); clearTimeout(state.postCloseTermTimer);
@@ -428,29 +430,27 @@ export class GroupQueue {
}, POST_CLOSE_SIGKILL_DELAY_MS); }, POST_CLOSE_SIGKILL_DELAY_MS);
} }
/**
* Signal the active agent process to wind down by writing a close sentinel.
*/
closeStdin( closeStdin(
groupJid: string, groupJid: string,
metadata?: { runId?: string; reason?: string }, metadata?: { runId?: string; reason?: string },
): void { ): void {
const state = this.getGroup(groupJid); const state = this.getGroup(groupJid);
if (state.runPhase === 'idle' || !state.ipcDir) return; if (state.runPhase === 'idle' || !state.ipcDir) return;
const runId = metadata?.runId ?? state.currentRunId;
state.lastCloseRequest = { runId, reason: metadata?.reason ?? null };
if (state.runPhase === 'running_messages') { if (state.runPhase === 'running_messages') {
transitionRunPhase(state, groupJid, 'closing_messages', { transitionRunPhase(state, groupJid, 'closing_messages', {
reason: metadata?.reason, reason: metadata?.reason,
runId: metadata?.runId ?? state.currentRunId, runId,
}); });
assertRunPhaseInvariants(state, groupJid); assertRunPhaseInvariants(state, groupJid);
} }
try { try {
writeCloseSentinel(state.ipcDir); writeCloseSentinel(state.ipcDir);
logger.info( logger.info(
{ {
groupJid, groupJid,
runId: metadata?.runId ?? state.currentRunId, runId,
ipcDir: state.ipcDir, ipcDir: state.ipcDir,
reason: metadata?.reason ?? 'unspecified', reason: metadata?.reason ?? 'unspecified',
}, },
@@ -460,7 +460,7 @@ export class GroupQueue {
logger.warn( logger.warn(
{ {
groupJid, groupJid,
runId: metadata?.runId ?? state.currentRunId, runId,
ipcDir: state.ipcDir, ipcDir: state.ipcDir,
reason: metadata?.reason ?? 'unspecified', reason: metadata?.reason ?? 'unspecified',
err, err,

View File

@@ -25,6 +25,7 @@ vi.mock('./message-runtime-follow-up.js', () => ({
import type { AgentOutput } from './agent-runner.js'; import type { AgentOutput } from './agent-runner.js';
import * as db from './db.js'; import * as db from './db.js';
import * as pairedExecutionContextModule from './paired-execution-context.js';
import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js'; import { createPairedExecutionLifecycle } from './message-agent-executor-paired.js';
const log = { const log = {
@@ -109,4 +110,94 @@ describe('createPairedExecutionLifecycle', () => {
expect(outputs).toEqual([]); expect(outputs).toEqual([]);
}); });
it('releases an owner turn interrupted by a human message without counting an owner failure', async () => {
const enqueueMessageCheck = vi.fn();
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'paired-task-human-interrupted',
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,
round_trip_count: 1,
review_requested_at: '2026-04-09T00:00:00.000Z',
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-09T00:00:00.000Z',
updated_at: '2026-04-09T00:00:01.000Z',
});
const lifecycle = createPairedExecutionLifecycle({
pairedExecutionContext: {
task: {
id: 'paired-task-human-interrupted',
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,
round_trip_count: 1,
review_requested_at: '2026-04-09T00:00:00.000Z',
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-09T00:00:00.000Z',
updated_at: '2026-04-09T00:00:00.000Z',
},
workspace: null,
envOverrides: {},
},
pairedTurnIdentity: {
turnId:
'paired-task-human-interrupted:2026-04-09T00:00:00.000Z:owner-turn',
taskId: 'paired-task-human-interrupted',
taskUpdatedAt: '2026-04-09T00:00:00.000Z',
intentKind: 'owner-turn',
role: 'owner',
},
completedRole: 'owner',
chatJid: 'group@test',
runId: 'run-human-interrupted',
enqueueMessageCheck,
getCloseReason: () => 'human-message-detected',
log,
});
expect(
lifecycle.recordFinalOutputBeforeDelivery(
'TASK_DONE\n부분 진행 결과를 닫기 전에 내보냅니다.',
),
).toBe(false);
lifecycle.updateSummary({
outputText: '아비터 판단을 내리겠습니다.',
});
lifecycle.markStatus('succeeded');
lifecycle.markSawOutput(false);
await lifecycle.asyncFinalize();
expect(
pairedExecutionContextModule.completePairedExecutionContext,
).not.toHaveBeenCalled();
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
expect(db.releasePairedTaskExecutionLease).toHaveBeenCalledWith({
taskId: 'paired-task-human-interrupted',
runId: 'run-human-interrupted',
});
expect(db.failPairedTurn).toHaveBeenCalledWith({
turnIdentity: expect.objectContaining({
taskId: 'paired-task-human-interrupted',
role: 'owner',
}),
error: '아비터 판단을 내리겠습니다.',
});
expect(enqueueMessageCheck).not.toHaveBeenCalled();
});
}); });

View File

@@ -19,12 +19,71 @@ import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js'; import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js'; import type { PairedTurnIdentity } from './paired-turn-identity.js';
import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js'; import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js';
import { isHumanMessageCloseReason } from './message-close-reasons.js';
import type { PairedRoomRole } from './types.js'; import type { PairedRoomRole } from './types.js';
type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>; type ExecutorLog = Pick<typeof logger, 'info' | 'warn'>;
const PAIRED_TASK_EXECUTION_LEASE_HEARTBEAT_MS = 30_000; const PAIRED_TASK_EXECUTION_LEASE_HEARTBEAT_MS = 30_000;
type PairedTaskRecord = NonNullable<ReturnType<typeof getPairedTaskById>>;
function releaseInterruptedPairedExecution(
taskId: string,
runId: string,
log: ExecutorLog,
): void {
log.info(
{ pairedTaskId: taskId, runId },
'Released paired execution lease without counting a failure because a human message interrupted the turn',
);
try {
releasePairedTaskExecutionLease({ taskId, runId });
} catch (err) {
log.warn(
{ pairedTaskId: taskId, runId, err },
'Failed to release paired execution lease after human interruption',
);
}
}
function completeStoredExecution(
taskId: string,
role: PairedRoomRole,
status: 'succeeded' | 'failed',
runId: string,
summary: string | null,
): void {
completePairedExecutionContext({
taskId,
role,
status,
runId,
summary,
});
}
async function notifyPairedCompletionIfNeeded(args: {
task: PairedTaskRecord | null | undefined;
chatJid: string;
onOutput?: (output: AgentOutput) => Promise<void>;
}): Promise<void> {
if (args.task?.status !== 'completed' || !args.task.completion_reason) return;
const sender = getLastHumanMessageSender(args.chatJid);
const mention = sender ? `<@${sender}>` : '';
const notifications: Record<string, string> = {
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
};
const message = notifications[args.task.completion_reason];
if (!message) return;
await args.onOutput?.({
status: 'success',
result: message,
output: { visibility: 'public', text: message },
phase: 'final',
});
}
export interface PairedExecutionLifecycle { export interface PairedExecutionLifecycle {
updateSummary(args: { updateSummary(args: {
outputText?: string | null; outputText?: string | null;
@@ -47,6 +106,7 @@ export function createPairedExecutionLifecycle(args: {
runId: string; runId: string;
enqueueMessageCheck: () => void; enqueueMessageCheck: () => void;
getDirectTerminalDeliveryText?: () => string | null; getDirectTerminalDeliveryText?: () => string | null;
getCloseReason?: () => string | null;
onOutput?: (output: AgentOutput) => Promise<void>; onOutput?: (output: AgentOutput) => Promise<void>;
log: ExecutorLog; log: ExecutorLog;
}): PairedExecutionLifecycle { }): PairedExecutionLifecycle {
@@ -58,6 +118,7 @@ export function createPairedExecutionLifecycle(args: {
runId, runId,
enqueueMessageCheck, enqueueMessageCheck,
getDirectTerminalDeliveryText, getDirectTerminalDeliveryText,
getCloseReason,
onOutput, onOutput,
log, log,
} = args; } = args;
@@ -76,6 +137,8 @@ export function createPairedExecutionLifecycle(args: {
pairedExecutionContext?.requiresVisibleVerdict === true; pairedExecutionContext?.requiresVisibleVerdict === true;
const missingVisibleVerdictSummary = const missingVisibleVerdictSummary =
'Execution completed without a visible terminal verdict.'; 'Execution completed without a visible terminal verdict.';
const wasInterruptedByHumanMessage = (): boolean =>
isHumanMessageCloseReason(getCloseReason?.() ?? null);
const currentRunOwnsActiveAttempt = (reason: string): boolean => { const currentRunOwnsActiveAttempt = (reason: string): boolean => {
if (!pairedTurnIdentity) { if (!pairedTurnIdentity) {
@@ -276,6 +339,7 @@ export function createPairedExecutionLifecycle(args: {
}, },
recordFinalOutputBeforeDelivery(outputText) { recordFinalOutputBeforeDelivery(outputText) {
if (wasInterruptedByHumanMessage()) return false;
if (!currentRunOwnsActiveAttempt('streamed-final-output')) { if (!currentRunOwnsActiveAttempt('streamed-final-output')) {
return false; return false;
} }
@@ -376,26 +440,34 @@ export function createPairedExecutionLifecycle(args: {
const sawOutputForFollowUp = missingVisibleVerdict const sawOutputForFollowUp = missingVisibleVerdict
? false ? false
: pairedSawOutput; : pairedSawOutput;
const interruptedByHumanMessage = wasInterruptedByHumanMessage();
if (pairedExecutionContext && !pairedExecutionCompleted) { if (pairedExecutionContext && !pairedExecutionCompleted) {
if (effectiveStatus === 'succeeded') { if (interruptedByHumanMessage) {
try { releaseInterruptedPairedExecution(
persistPairedTurnOutputIfNeeded(); pairedExecutionContext.task.id,
} catch (err) { runId,
log.warn( log,
{ pairedTaskId: pairedExecutionContext.task.id, err }, );
'Failed to store paired turn output', } else {
); if (effectiveStatus === 'succeeded') {
try {
persistPairedTurnOutputIfNeeded();
} catch (err) {
log.warn(
{ pairedTaskId: pairedExecutionContext.task.id, err },
'Failed to store paired turn output',
);
}
} }
completeStoredExecution(
pairedExecutionContext.task.id,
completedRole,
effectiveStatus,
runId,
pairedExecutionSummary,
);
} }
completePairedExecutionContext({
taskId: pairedExecutionContext.task.id,
role: completedRole,
status: effectiveStatus,
runId,
summary: pairedExecutionSummary,
});
pairedExecutionCompleted = true; pairedExecutionCompleted = true;
} }
@@ -407,27 +479,15 @@ export function createPairedExecutionLifecycle(args: {
if (!pairedExecutionContext) { if (!pairedExecutionContext) {
return; return;
} }
if (interruptedByHumanMessage) {
const finishedTask = getPairedTaskById(pairedExecutionContext.task.id); return;
if (
finishedTask?.status === 'completed' &&
finishedTask.completion_reason
) {
const sender = getLastHumanMessageSender(chatJid);
const mention = sender ? `<@${sender}>` : '';
const notifications: Record<string, string> = {
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
};
const message = notifications[finishedTask.completion_reason];
if (message) {
await onOutput?.({
status: 'success',
result: message,
output: { visibility: 'public', text: message },
phase: 'final',
});
}
} }
const finishedTask = getPairedTaskById(pairedExecutionContext.task.id);
await notifyPairedCompletionIfNeeded({
task: finishedTask,
chatJid,
onOutput,
});
const queueAction = const queueAction =
directTerminalOutput && directTerminalOutput &&

View File

@@ -92,7 +92,12 @@ async function enrichArbiterPromptWithMoa(args: {
export interface MessageAgentExecutorDeps { export interface MessageAgentExecutorDeps {
assistantName: string; assistantName: string;
queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'> & queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'> &
Partial<Pick<GroupQueue, 'getDirectTerminalDeliveryForRun'>>; Partial<
Pick<
GroupQueue,
'getDirectTerminalDeliveryForRun' | 'getCloseReasonForRun'
>
>;
getRoomBindings: () => Record<string, RegisteredGroup>; getRoomBindings: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>; getSessions: () => Record<string, string>;
persistSession: (groupFolder: string, sessionId: string) => void; persistSession: (groupFolder: string, sessionId: string) => void;
@@ -180,6 +185,8 @@ export async function runAgentForGroup(
runId, runId,
runtimePairedTurnIdentity?.role ?? activeRole, runtimePairedTurnIdentity?.role ?? activeRole,
) ?? null, ) ?? null,
getCloseReason: () =>
deps.queue.getCloseReasonForRun?.(chatJid, runId) ?? null,
onOutput, onOutput,
log, log,
}); });

View File

@@ -0,0 +1,7 @@
export const HUMAN_MESSAGE_DETECTED_CLOSE_REASON = 'human-message-detected';
export function isHumanMessageCloseReason(
reason: string | null | undefined,
): boolean {
return reason === HUMAN_MESSAGE_DETECTED_CLOSE_REASON;
}

View File

@@ -4,6 +4,7 @@ import {
buildQueuedTurnDispatch, buildQueuedTurnDispatch,
executeBotOnlyPairedFollowUpAction, executeBotOnlyPairedFollowUpAction,
} from './message-runtime-flow.js'; } from './message-runtime-flow.js';
import { HUMAN_MESSAGE_DETECTED_CLOSE_REASON } from './message-close-reasons.js';
import { import {
advanceLastAgentCursor, advanceLastAgentCursor,
filterLoopingPairedBotMessages, filterLoopingPairedBotMessages,
@@ -305,7 +306,7 @@ export async function processLoopGroupMessages(args: {
if (hasExternalHumanMessage(processableGroupMessages)) { if (hasExternalHumanMessage(processableGroupMessages)) {
const interruptedActiveRun = args.isRunningMessageTurn(chatJid); const interruptedActiveRun = args.isRunningMessageTurn(chatJid);
if (interruptedActiveRun) { if (interruptedActiveRun) {
args.closeStdin('human-message-detected'); args.closeStdin(HUMAN_MESSAGE_DETECTED_CLOSE_REASON);
} }
args.enqueueMessageCheck(); args.enqueueMessageCheck();
logger.info( logger.info(

View File

@@ -137,6 +137,7 @@ export function buildPendingPairedTurn(args: {
turnOutputs, turnOutputs,
recentHumanMessages, recentHumanMessages,
lastHumanMessage, lastHumanMessage,
taskCreatedAt: task.created_at,
}), }),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,
@@ -188,6 +189,7 @@ export function buildPendingPairedTurn(args: {
turnOutputs, turnOutputs,
recentHumanMessages, recentHumanMessages,
lastHumanMessage, lastHumanMessage,
taskCreatedAt: task.created_at,
}), }),
channel: resolveChannel(taskStatus), channel: resolveChannel(taskStatus),
cursor, cursor,

View File

@@ -12,14 +12,17 @@ import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
const CARRY_FORWARD_MARKER = const CARRY_FORWARD_MARKER =
'[Carried forward context from the previous task: latest owner final]'; '[Carried forward context from the previous task: latest owner final]';
function makeHumanMessage(content: string): NewMessage { function makeHumanMessage(
content: string,
timestamp: string = '2026-04-20T01:00:00.000Z',
): NewMessage {
return { return {
id: `msg-${content}`, id: `msg-${content}`,
chat_jid: 'group@test', chat_jid: 'group@test',
sender: 'user@test', sender: 'user@test',
sender_name: 'User', sender_name: 'User',
content, content,
timestamp: '2026-04-20T01:00:00.000Z', timestamp,
is_bot_message: false, is_bot_message: false,
is_from_me: false, is_from_me: false,
}; };
@@ -134,6 +137,32 @@ describe('message-runtime-prompts output-only context', () => {
expect(prompt).not.toContain('과거 요청을 다시 기준으로 보면 안 됨'); expect(prompt).not.toContain('과거 요청을 다시 기준으로 보면 안 됨');
}); });
it('includes current task user scope in reviewer pending prompts without pulling older human messages', () => {
const prompt = buildReviewerPendingPrompt({
chatJid: 'group@test',
timezone: 'UTC',
turnOutputs: [
makeTurnOutput('TASK_DONE\n현재 owner 결과', 'owner', {
turn_number: 1,
created_at: '2026-04-20T02:10:00.000Z',
}),
],
recentHumanMessages: [
makeHumanMessage('이전 작업의 gap 요청', '2026-04-20T01:50:00.000Z'),
makeHumanMessage(
'뒤쪽 트레일 주변 디졸브 넣어줘',
'2026-04-20T02:00:01.000Z',
),
],
lastHumanMessage: '뒤쪽 트레일 주변 디졸브 넣어줘',
taskCreatedAt: '2026-04-20T02:00:00.000Z',
});
expect(prompt).toContain('뒤쪽 트레일 주변 디졸브 넣어줘');
expect(prompt).toContain('현재 owner 결과');
expect(prompt).not.toContain('이전 작업의 gap 요청');
});
it('prepends a carry-forward warning to owner pending prompts', () => { it('prepends a carry-forward warning to owner pending prompts', () => {
const prompt = buildOwnerPendingPrompt({ const prompt = buildOwnerPendingPrompt({
chatJid: 'group@test', chatJid: 'group@test',
@@ -175,6 +204,38 @@ describe('message-runtime-prompts output-only context', () => {
expect(prompt).not.toContain('이전 작업의 사용자 메시지'); expect(prompt).not.toContain('이전 작업의 사용자 메시지');
}); });
it('includes current task user scope in owner pending prompts without pulling older human messages', () => {
const prompt = buildOwnerPendingPrompt({
chatJid: 'group@test',
timezone: 'UTC',
turnOutputs: [
makeTurnOutput('DONE_WITH_CONCERNS\n현재 reviewer 피드백', 'reviewer', {
id: 2,
turn_number: 2,
created_at: '2026-04-20T02:11:00.000Z',
}),
],
recentHumanMessages: [
makeHumanMessage(
'이전 작업의 사용자 메시지',
'2026-04-20T01:50:00.000Z',
),
makeHumanMessage(
'현재 task에서 추가한 조건',
'2026-04-20T02:00:01.000Z',
),
],
lastHumanMessage: '현재 task에서 추가한 조건',
taskCreatedAt: '2026-04-20T02:00:00.000Z',
});
expect(prompt).toContain('현재 task에서 추가한 조건');
expect(prompt).toContain('현재 reviewer 피드백');
expect(prompt).not.toContain('이전 작업의 사용자 메시지');
});
});
describe('message-runtime-prompts arbiter output context', () => {
it('keeps arbiter prompts output-only when turn outputs exist', () => { it('keeps arbiter prompts output-only when turn outputs exist', () => {
const prompt = buildArbiterPromptForTask({ const prompt = buildArbiterPromptForTask({
task: makeTask({ status: 'arbiter_requested' }), task: makeTask({ status: 'arbiter_requested' }),
@@ -189,8 +250,12 @@ describe('message-runtime-prompts output-only context', () => {
turn_number: 2, turn_number: 2,
}), }),
], ],
recentMessages: [makeHumanMessage('예전 유저 지시')], recentMessages: [
labeledRecentMessages: [makeHumanMessage('예전 유저 지시')], makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'),
],
labeledRecentMessages: [
makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'),
],
}); });
expect(prompt).toContain('owner 주장'); expect(prompt).toContain('owner 주장');
@@ -215,8 +280,12 @@ describe('message-runtime-prompts output-only context', () => {
}, },
); );
}), }),
recentMessages: [makeHumanMessage('예전 유저 지시')], recentMessages: [
labeledRecentMessages: [makeHumanMessage('예전 유저 지시')], makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'),
],
labeledRecentMessages: [
makeHumanMessage('예전 유저 지시', '2026-04-20T00:50:00.000Z'),
],
}); });
expect(prompt).not.toContain('arbiter-context-output-01'); expect(prompt).not.toContain('arbiter-context-output-01');
@@ -226,6 +295,46 @@ describe('message-runtime-prompts output-only context', () => {
expect(prompt).not.toContain('예전 유저 지시'); expect(prompt).not.toContain('예전 유저 지시');
}); });
it('includes current task user scope in arbiter prompts while keeping the output cap', () => {
const prompt = buildArbiterPromptForTask({
task: makeTask({
status: 'arbiter_requested',
created_at: '2026-04-20T02:00:00.000Z',
}),
chatJid: 'group@test',
timezone: 'UTC',
turnOutputs: Array.from({ length: 8 }, (_, index) => {
const turnNumber = index + 1;
return makeTurnOutput(
`arbiter-context-output-${String(turnNumber).padStart(2, '0')}`,
turnNumber % 2 === 0 ? 'reviewer' : 'owner',
{
id: turnNumber,
turn_number: turnNumber,
created_at: `2026-04-20T02:${String(turnNumber).padStart(2, '0')}:00.000Z`,
},
);
}),
recentMessages: [
makeHumanMessage('이전 작업의 유저 지시', '2026-04-20T01:50:00.000Z'),
makeHumanMessage(
'현재 task의 트레일 디졸브 요청',
'2026-04-20T02:00:01.000Z',
),
],
labeledRecentMessages: [],
});
expect(prompt).toContain('현재 task의 트레일 디졸브 요청');
expect(prompt).not.toContain('이전 작업의 유저 지시');
expect(prompt).not.toContain('arbiter-context-output-01');
expect(prompt).not.toContain('arbiter-context-output-02');
expect(prompt).toContain('arbiter-context-output-03');
expect(prompt).toContain('arbiter-context-output-08');
});
});
describe('message-runtime-prompts prompt hygiene', () => {
it('preserves turn output order instead of timestamp interleaving', () => { it('preserves turn output order instead of timestamp interleaving', () => {
const prompt = buildReviewerPendingPrompt({ const prompt = buildReviewerPendingPrompt({
chatJid: 'group@test', chatJid: 'group@test',

View File

@@ -9,6 +9,7 @@ const CARRIED_FORWARD_OWNER_FINAL_GUIDANCE = `System note:
If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`; If you see a message beginning with "${CARRIED_FORWARD_OWNER_FINAL_MARKER}", treat it as background only. Do not repeat, continue, or answer that carried-forward final directly. Respond only to the latest human request and the current task.`;
const ARBITER_TURN_OUTPUT_CONTEXT_LIMIT = 6; const ARBITER_TURN_OUTPUT_CONTEXT_LIMIT = 6;
const TASK_USER_CONTEXT_START_SKEW_MS = 5_000;
function turnOutputsToMessages( function turnOutputsToMessages(
outputs: PairedTurnOutput[], outputs: PairedTurnOutput[],
@@ -47,6 +48,23 @@ function latestTurnOutputs(
return outputs.slice(-limit); return outputs.slice(-limit);
} }
function currentTaskHumanMessages(
messages: NewMessage[],
taskCreatedAt: string | null | undefined,
): NewMessage[] {
if (!taskCreatedAt) return [];
const taskStartMs = Date.parse(taskCreatedAt);
if (!Number.isFinite(taskStartMs)) return [];
return messages.filter((message) => {
if (message.is_bot_message) return false;
const messageMs = Date.parse(message.timestamp);
return (
Number.isFinite(messageMs) &&
messageMs >= taskStartMs - TASK_USER_CONTEXT_START_SKEW_MS
);
});
}
function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean { function hasCarriedForwardOwnerFinal(outputs: PairedTurnOutput[]): boolean {
return outputs.some((output) => return outputs.some((output) =>
output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER), output.output_text.startsWith(CARRIED_FORWARD_OWNER_FINAL_MARKER),
@@ -95,9 +113,13 @@ function turnOutputsOnlyPrompt(
chatJid: string, chatJid: string,
timezone: string, timezone: string,
turnOutputs: PairedTurnOutput[], turnOutputs: PairedTurnOutput[],
taskHumanMessages: NewMessage[] = [],
): string { ): string {
return prependCarriedForwardGuidance( return prependCarriedForwardGuidance(
formatMessages(turnOutputsToMessages(turnOutputs, chatJid), timezone), formatMessages(
[...taskHumanMessages, ...turnOutputsToMessages(turnOutputs, chatJid)],
timezone,
),
turnOutputs, turnOutputs,
); );
} }
@@ -108,9 +130,15 @@ export function buildReviewerPendingPrompt(args: {
turnOutputs: PairedTurnOutput[]; turnOutputs: PairedTurnOutput[];
recentHumanMessages: NewMessage[]; recentHumanMessages: NewMessage[];
lastHumanMessage: string | null | undefined; lastHumanMessage: string | null | undefined;
taskCreatedAt?: string | null;
}): string { }): string {
if (args.turnOutputs.length > 0) { if (args.turnOutputs.length > 0) {
return turnOutputsOnlyPrompt(args.chatJid, args.timezone, args.turnOutputs); return turnOutputsOnlyPrompt(
args.chatJid,
args.timezone,
args.turnOutputs,
currentTaskHumanMessages(args.recentHumanMessages, args.taskCreatedAt),
);
} }
if (!args.lastHumanMessage) { if (!args.lastHumanMessage) {
@@ -126,9 +154,15 @@ export function buildOwnerPendingPrompt(args: {
turnOutputs: PairedTurnOutput[]; turnOutputs: PairedTurnOutput[];
recentHumanMessages: NewMessage[]; recentHumanMessages: NewMessage[];
lastHumanMessage: string | null | undefined; lastHumanMessage: string | null | undefined;
taskCreatedAt?: string | null;
}): string { }): string {
if (args.turnOutputs.length > 0) { if (args.turnOutputs.length > 0) {
return turnOutputsOnlyPrompt(args.chatJid, args.timezone, args.turnOutputs); return turnOutputsOnlyPrompt(
args.chatJid,
args.timezone,
args.turnOutputs,
currentTaskHumanMessages(args.recentHumanMessages, args.taskCreatedAt),
);
} }
if (!args.lastHumanMessage) { if (!args.lastHumanMessage) {
@@ -146,15 +180,22 @@ export function buildArbiterPromptForTask(args: {
recentMessages: NewMessage[]; recentMessages: NewMessage[];
labeledRecentMessages: NewMessage[]; labeledRecentMessages: NewMessage[];
}): string { }): string {
const taskHumanMessages = currentTaskHumanMessages(
args.recentMessages,
args.task.created_at,
);
const messages = const messages =
args.turnOutputs.length > 0 args.turnOutputs.length > 0
? turnOutputsToMessages( ? [
latestTurnOutputs( ...taskHumanMessages,
args.turnOutputs, ...turnOutputsToMessages(
ARBITER_TURN_OUTPUT_CONTEXT_LIMIT, latestTurnOutputs(
args.turnOutputs,
ARBITER_TURN_OUTPUT_CONTEXT_LIMIT,
),
args.chatJid,
), ),
args.chatJid, ]
)
: args.labeledRecentMessages; : args.labeledRecentMessages;
return buildArbiterContextPrompt({ return buildArbiterContextPrompt({

View File

@@ -204,6 +204,8 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
}); });
return ownership.state !== 'inactive'; return ownership.state !== 'inactive';
}, },
getCloseReason: () =>
deps.queue.getCloseReasonForRun?.(chatJid, runId) ?? null,
deliverFinalText: async (text, options) => { deliverFinalText: async (text, options) => {
try { try {
return await deps.deliverFinalText({ return await deps.deliverFinalText({

View File

@@ -803,3 +803,53 @@ describe('MessageTurnController outbound audit logging', () => {
}); });
}); });
}); });
describe('MessageTurnController human interruptions', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('does not replay owner progress as final when a human message interrupted the turn', async () => {
const channel = { ...makeChannel(), name: 'discord' } satisfies Channel;
const deliverFinalText = vi.fn().mockResolvedValue(true);
const controller = new MessageTurnController({
chatJid: 'dc:test-room',
group: makeGroup(),
runId: 'run-owner-human-interrupted',
channel,
idleTimeout: 1_000,
failureFinalText: '실패',
isClaudeCodeAgent: true,
clearSession: vi.fn(),
requestClose: vi.fn(),
deliverFinalText,
getCloseReason: () => 'human-message-detected',
deliveryRole: 'owner',
pairedTurnIdentity: {
turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
taskId: 'task-1',
taskUpdatedAt: '2026-04-10T14:22:00.000Z',
intentKind: 'owner-turn',
role: 'owner',
},
});
await controller.start();
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '아비터 판단을 내리겠습니다.',
} as any);
await controller.handleOutput({
status: 'success',
phase: 'progress',
result: '추가 확인 중입니다.',
} as any);
await flushAsync();
await controller.finish('success');
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
expect(deliverFinalText).not.toHaveBeenCalled();
});
});

View File

@@ -9,6 +9,7 @@ import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
import { formatElapsedKorean } from './utils.js'; import { formatElapsedKorean } from './utils.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js'; import type { PairedTurnIdentity } from './paired-turn-identity.js';
import { isHumanMessageCloseReason } from './message-close-reasons.js';
import { import {
normalizeAgentOutputPhase, normalizeAgentOutputPhase,
toVisiblePhase, toVisiblePhase,
@@ -45,6 +46,7 @@ interface MessageTurnControllerOptions {
}, },
) => Promise<boolean>; ) => Promise<boolean>;
canDeliverFinalText?: () => boolean; canDeliverFinalText?: () => boolean;
getCloseReason?: () => string | null;
allowProgressReplayWithoutFinal?: boolean; allowProgressReplayWithoutFinal?: boolean;
deliveryRole?: PairedRoomRole | null; deliveryRole?: PairedRoomRole | null;
deliveryServiceId?: string | null; deliveryServiceId?: string | null;
@@ -350,11 +352,9 @@ export class MessageTurnController {
visiblePhase: VisiblePhase; visiblePhase: VisiblePhase;
}> { }> {
await this.deactivateTyping('turn:finish', { outputStatus }); await this.deactivateTyping('turn:finish', { outputStatus });
if (outputStatus === 'error') { if (outputStatus === 'error') {
this.hadError = true; this.hadError = true;
} }
if ( if (
outputStatus === 'success' && outputStatus === 'success' &&
this.visiblePhase === 'progress' && this.visiblePhase === 'progress' &&
@@ -362,7 +362,9 @@ export class MessageTurnController {
this.latestProgressTextForFinal this.latestProgressTextForFinal
) { ) {
const replayText = this.latestProgressTextForFinal; const replayText = this.latestProgressTextForFinal;
if (this.options.allowProgressReplayWithoutFinal !== false) { if (isHumanMessageCloseReason(this.options.getCloseReason?.() ?? null)) {
this.resetProgressState();
} else if (this.options.allowProgressReplayWithoutFinal !== false) {
this.log.info( this.log.info(
'Sending a separate final message from the last progress output after agent completion', 'Sending a separate final message from the last progress output after agent completion',
); );
@@ -787,13 +789,11 @@ export class MessageTurnController {
} }
await this.publishTerminalText(this.options.failureFinalText); await this.publishTerminalText(this.options.failureFinalText);
} }
private requestAgentClose(reason: string): void { private requestAgentClose(reason: string): void {
if (this.closeRequested) return; if (this.closeRequested) return;
this.closeRequested = true; this.closeRequested = true;
this.options.requestClose(reason); this.options.requestClose(reason);
} }
private async sendProgressMessage(text: string): Promise<void> { private async sendProgressMessage(text: string): Promise<void> {
if ( if (
this.options.canDeliverFinalText && this.options.canDeliverFinalText &&