fix: suppress paired completion status reentry

This commit is contained in:
ejclaw
2026-04-09 14:29:14 +09:00
parent 000e672ca8
commit a632137198
4 changed files with 216 additions and 2 deletions

View File

@@ -138,4 +138,41 @@ describe('filterProcessableMessages', () => {
expect(result[0].id).toBe('human-1');
expect(result[1].id).toBe('other-1');
});
it('filters paired execution status control bot messages in paired rooms', () => {
const isOwn = (m: NewMessage) =>
m.is_bot_message === true && m.sender === OWN_BOT_ID;
const result = filterProcessableMessages(
[
makeMsg({ id: 'human-1', content: 'human' }),
makeMsg({
id: 'control-1',
sender: 'other-bot-456',
sender_name: 'OtherBot',
content: '<@216851709744513024> ✅ 작업 완료.',
is_bot_message: true,
}),
makeMsg({
id: 'control-2',
sender: 'other-bot-456',
sender_name: 'OtherBot',
content: '⚠️ 자동 해결 불가 — 확인이 필요합니다.',
is_bot_message: true,
}),
makeMsg({
id: 'other-1',
sender: 'other-bot-456',
sender_name: 'OtherBot',
content: 'partner response',
is_bot_message: true,
}),
],
true,
isOwn,
);
expect(result).toHaveLength(2);
expect(result[0].id).toBe('human-1');
expect(result[1].id).toBe('other-1');
});
});

View File

@@ -1,6 +1,13 @@
import { isSessionCommandControlMessage } from './session-commands.js';
import { NewMessage } from './types.js';
const PAIRED_EXECUTION_CONTROL_MESSAGE_PATTERN =
/^(?:<@\d+>\s+)?(?:✅ 작업 완료\.|⚠️ 자동 해결 불가 — 확인이 필요합니다\.|⚠️ 중재자 판단: 사람 개입이 필요합니다\.)$/;
function isPairedExecutionControlMessage(content: string): boolean {
return PAIRED_EXECUTION_CONTROL_MESSAGE_PATTERN.test(content.trim());
}
/**
* Filter messages before processing.
* - Normal rooms: drop all bot messages.
@@ -16,7 +23,8 @@ export function filterProcessableMessages(
(message) =>
!(
message.is_bot_message &&
isSessionCommandControlMessage(message.content)
(isSessionCommandControlMessage(message.content) ||
isPairedExecutionControlMessage(message.content))
),
);

View File

@@ -244,7 +244,6 @@ export function createPairedExecutionLifecycle(args: {
const sender = getLastHumanMessageSender(chatJid);
const mention = sender ? `<@${sender}>` : '';
const notifications: Record<string, string> = {
done: `${mention} ✅ 작업 완료.`,
escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`,
arbiter_escalated: `${mention} ⚠️ 중재자 판단: 사람 개입이 필요합니다.`,
};

View File

@@ -837,6 +837,176 @@ describe('runAgentForGroup room memory', () => {
});
});
it('does not emit an extra public done notification after owner finalization completes the paired task', async () => {
const group = {
...makeGroup(),
folder: 'test-group',
workDir: '/repo/canonical',
};
const outputs: string[] = [];
vi.mocked(db.getLastHumanMessageSender).mockReturnValue('216851709744513024');
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-final-done',
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: 'merge_ready',
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: {},
});
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'paired-task-final-done',
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: 'completed',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: 'done',
created_at: '2026-04-09T00:00:00.000Z',
updated_at: '2026-04-09T00:00:01.000Z',
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: 'DONE\nfinalized',
output: { visibility: 'public', text: 'DONE\nfinalized' },
phase: 'final',
});
return {
status: 'success',
result: 'DONE\nfinalized',
};
},
);
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'finalize',
chatJid: 'group@test',
runId: 'run-final-done',
onOutput: async (output) => {
if (output.output?.visibility === 'public' && output.output.text) {
outputs.push(output.output.text);
}
},
});
expect(result).toBe('success');
expect(outputs).toEqual(['DONE\nfinalized']);
});
it('still emits escalation notifications when the paired task completes with human intervention required', async () => {
const group = {
...makeGroup(),
folder: 'test-group',
workDir: '/repo/canonical',
};
const outputs: string[] = [];
vi.mocked(db.getLastHumanMessageSender).mockReturnValue('216851709744513024');
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-final-escalated',
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: 'merge_ready',
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: {},
});
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'paired-task-final-escalated',
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: 'completed',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: 'escalated',
created_at: '2026-04-09T00:00:00.000Z',
updated_at: '2026-04-09T00:00:01.000Z',
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: 'BLOCKED\nhuman intervention required',
output: {
visibility: 'public',
text: 'BLOCKED\nhuman intervention required',
},
phase: 'final',
});
return {
status: 'success',
result: 'BLOCKED\nhuman intervention required',
};
},
);
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'finalize',
chatJid: 'group@test',
runId: 'run-final-escalated',
onOutput: async (output) => {
if (output.output?.visibility === 'public' && output.output.text) {
outputs.push(output.output.text);
}
},
});
expect(result).toBe('success');
expect(outputs).toEqual([
'BLOCKED\nhuman intervention required',
'<@216851709744513024> ⚠️ 자동 해결 불가 — 확인이 필요합니다.',
]);
});
it('logs streamed activity with resolved execution attribution', async () => {
const group = {
...makeGroup(),