Add STEP_DONE guards, verdict storage, and stale delivery suppression

This commit is contained in:
ejclaw
2026-04-23 15:27:49 +09:00
parent afd9c7a538
commit 9ef79e3828
18 changed files with 767 additions and 64 deletions

View File

@@ -1242,10 +1242,53 @@ describe('paired task state', () => {
expect(outputs.map((output) => output.turn_number)).toEqual([1, 2]); expect(outputs.map((output) => output.turn_number)).toEqual([1, 2]);
expect(outputs[0].role).toBe('owner'); expect(outputs[0].role).toBe('owner');
expect(outputs[0].output_text).toHaveLength(50_000); expect(outputs[0].output_text).toHaveLength(50_000);
expect(outputs[0].verdict).toBe('continue');
expect(outputs[1].output_text).toBe('review turn'); expect(outputs[1].output_text).toBe('review turn');
expect(outputs[1].verdict).toBe('continue');
expect(getLatestTurnNumber('paired-task-turn-output')).toBe(2); expect(getLatestTurnNumber('paired-task-turn-output')).toBe(2);
}); });
it('stores the parsed visible verdict with paired turn outputs', () => {
createPairedTask({
id: 'paired-task-turn-output-verdict',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: null,
plan_notes: null,
round_trip_count: 0,
review_requested_at: null,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
insertPairedTurnOutput(
'paired-task-turn-output-verdict',
1,
'owner',
'STEP_DONE\n1단계 완료',
);
insertPairedTurnOutput(
'paired-task-turn-output-verdict',
2,
'owner',
'TASK_DONE\n요청 범위 전체 완료',
);
const outputs = getPairedTurnOutputs('paired-task-turn-output-verdict');
expect(outputs.map((output) => output.verdict)).toEqual([
'step_done',
'task_done',
]);
});
it('preserves explicit created_at when inserting a paired turn output', () => { it('preserves explicit created_at when inserting a paired turn output', () => {
createPairedTask({ createPairedTask({
id: 'paired-task-turn-output-created-at', id: 'paired-task-turn-output-created-at',

View File

@@ -119,6 +119,10 @@ export function applyBaseSchema(database: Database): void {
review_requested_at TEXT, review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0, round_trip_count INTEGER NOT NULL DEFAULT 0,
owner_failure_count INTEGER NOT NULL DEFAULT 0, owner_failure_count INTEGER NOT NULL DEFAULT 0,
owner_step_done_streak INTEGER NOT NULL DEFAULT 0,
finalize_step_done_count INTEGER NOT NULL DEFAULT 0,
task_done_then_user_reopen_count INTEGER NOT NULL DEFAULT 0,
empty_step_done_streak INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT, arbiter_verdict TEXT,
arbiter_requested_at TEXT, arbiter_requested_at TEXT,
@@ -154,6 +158,7 @@ export function applyBaseSchema(database: Database): void {
turn_number INTEGER NOT NULL, turn_number INTEGER NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
output_text TEXT NOT NULL, output_text TEXT NOT NULL,
verdict TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(task_id, turn_number, role) UNIQUE(task_id, turn_number, role)
); );

View File

@@ -36,6 +36,7 @@ function getExpectedSchemaMigrations(): Array<{
{ version: 9, name: 'paired_workspace_project_schema_cleanup' }, { version: 9, name: 'paired_workspace_project_schema_cleanup' },
{ version: 10, name: 'paired_turn_provenance_upgrade' }, { version: 10, name: 'paired_turn_provenance_upgrade' },
{ version: 11, name: 'owner_failure_count' }, { version: 11, name: 'owner_failure_count' },
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
]; ];
} }

View File

@@ -0,0 +1,75 @@
import type { Database } from 'bun:sqlite';
import { tableHasColumn } from './helpers.js';
import type { SchemaMigrationDefinition } from './types.js';
export const PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION: SchemaMigrationDefinition =
{
version: 12,
name: 'paired_verdict_and_step_telemetry',
apply(database: Database) {
if (!tableHasColumn(database, 'paired_turn_outputs', 'verdict')) {
database.exec(`
ALTER TABLE paired_turn_outputs
ADD COLUMN verdict TEXT
`);
}
if (!tableHasColumn(database, 'paired_tasks', 'owner_step_done_streak')) {
database.exec(`
ALTER TABLE paired_tasks
ADD COLUMN owner_step_done_streak INTEGER NOT NULL DEFAULT 0
`);
}
if (
!tableHasColumn(database, 'paired_tasks', 'finalize_step_done_count')
) {
database.exec(`
ALTER TABLE paired_tasks
ADD COLUMN finalize_step_done_count INTEGER NOT NULL DEFAULT 0
`);
}
if (
!tableHasColumn(
database,
'paired_tasks',
'task_done_then_user_reopen_count',
)
) {
database.exec(`
ALTER TABLE paired_tasks
ADD COLUMN task_done_then_user_reopen_count INTEGER NOT NULL DEFAULT 0
`);
}
if (!tableHasColumn(database, 'paired_tasks', 'empty_step_done_streak')) {
database.exec(`
ALTER TABLE paired_tasks
ADD COLUMN empty_step_done_streak INTEGER NOT NULL DEFAULT 0
`);
}
database.exec(`
UPDATE paired_tasks
SET owner_step_done_streak = 0
WHERE owner_step_done_streak IS NULL
`);
database.exec(`
UPDATE paired_tasks
SET finalize_step_done_count = 0
WHERE finalize_step_done_count IS NULL
`);
database.exec(`
UPDATE paired_tasks
SET task_done_then_user_reopen_count = 0
WHERE task_done_then_user_reopen_count IS NULL
`);
database.exec(`
UPDATE paired_tasks
SET empty_step_done_streak = 0
WHERE empty_step_done_streak IS NULL
`);
},
};

View File

@@ -11,6 +11,7 @@ import { PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION } from './008_paired-task-schema-c
import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired-workspace-project-schema-cleanup.js'; import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired-workspace-project-schema-cleanup.js';
import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-provenance-upgrade.js'; import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-provenance-upgrade.js';
import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js'; import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js';
import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js';
import type { import type {
SchemaMigrationArgs, SchemaMigrationArgs,
SchemaMigrationDefinition, SchemaMigrationDefinition,
@@ -30,6 +31,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION, PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION,
PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION, PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION,
OWNER_FAILURE_COUNT_MIGRATION, OWNER_FAILURE_COUNT_MIGRATION,
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
]; ];
function ensureSchemaMigrationsTable(database: Database): void { function ensureSchemaMigrationsTable(database: Database): void {

View File

@@ -51,6 +51,10 @@ export type PairedTaskUpdates = Partial<
| 'review_requested_at' | 'review_requested_at'
| 'round_trip_count' | 'round_trip_count'
| 'owner_failure_count' | 'owner_failure_count'
| 'owner_step_done_streak'
| 'finalize_step_done_count'
| 'task_done_then_user_reopen_count'
| 'empty_step_done_streak'
| 'status' | 'status'
| 'arbiter_verdict' | 'arbiter_verdict'
| 'arbiter_requested_at' | 'arbiter_requested_at'
@@ -173,6 +177,10 @@ export function createPairedTaskInDatabase(
review_requested_at, review_requested_at,
round_trip_count, round_trip_count,
owner_failure_count, owner_failure_count,
owner_step_done_streak,
finalize_step_done_count,
task_done_then_user_reopen_count,
empty_step_done_streak,
status, status,
arbiter_verdict, arbiter_verdict,
arbiter_requested_at, arbiter_requested_at,
@@ -180,7 +188,7 @@ export function createPairedTaskInDatabase(
created_at, created_at,
updated_at updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
) )
.run( .run(
@@ -198,6 +206,10 @@ export function createPairedTaskInDatabase(
task.review_requested_at, task.review_requested_at,
task.round_trip_count, task.round_trip_count,
task.owner_failure_count ?? 0, task.owner_failure_count ?? 0,
task.owner_step_done_streak ?? 0,
task.finalize_step_done_count ?? 0,
task.task_done_then_user_reopen_count ?? 0,
task.empty_step_done_streak ?? 0,
task.status, task.status,
task.arbiter_verdict, task.arbiter_verdict,
task.arbiter_requested_at, task.arbiter_requested_at,
@@ -306,6 +318,22 @@ export function updatePairedTaskInDatabase(
fields.push('owner_failure_count = ?'); fields.push('owner_failure_count = ?');
values.push(updates.owner_failure_count); values.push(updates.owner_failure_count);
} }
if (updates.owner_step_done_streak !== undefined) {
fields.push('owner_step_done_streak = ?');
values.push(updates.owner_step_done_streak);
}
if (updates.finalize_step_done_count !== undefined) {
fields.push('finalize_step_done_count = ?');
values.push(updates.finalize_step_done_count);
}
if (updates.task_done_then_user_reopen_count !== undefined) {
fields.push('task_done_then_user_reopen_count = ?');
values.push(updates.task_done_then_user_reopen_count);
}
if (updates.empty_step_done_streak !== undefined) {
fields.push('empty_step_done_streak = ?');
values.push(updates.empty_step_done_streak);
}
if (updates.status !== undefined) { if (updates.status !== undefined) {
fields.push('status = ?'); fields.push('status = ?');
values.push(updates.status); values.push(updates.status);
@@ -369,6 +397,22 @@ export function updatePairedTaskIfUnchangedInDatabase(
fields.push('owner_failure_count = ?'); fields.push('owner_failure_count = ?');
values.push(updates.owner_failure_count); values.push(updates.owner_failure_count);
} }
if (updates.owner_step_done_streak !== undefined) {
fields.push('owner_step_done_streak = ?');
values.push(updates.owner_step_done_streak);
}
if (updates.finalize_step_done_count !== undefined) {
fields.push('finalize_step_done_count = ?');
values.push(updates.finalize_step_done_count);
}
if (updates.task_done_then_user_reopen_count !== undefined) {
fields.push('task_done_then_user_reopen_count = ?');
values.push(updates.task_done_then_user_reopen_count);
}
if (updates.empty_step_done_streak !== undefined) {
fields.push('empty_step_done_streak = ?');
values.push(updates.empty_step_done_streak);
}
if (updates.status !== undefined) { if (updates.status !== undefined) {
fields.push('status = ?'); fields.push('status = ?');
values.push(updates.status); values.push(updates.status);

View File

@@ -1,6 +1,7 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { parseVisibleVerdict } from '../paired-verdict.js';
import { PairedRoomRole, PairedTurnOutput } from '../types.js'; import { PairedRoomRole, PairedTurnOutput } from '../types.js';
const MAX_TURN_OUTPUT_CHARS = 50_000; const MAX_TURN_OUTPUT_CHARS = 50_000;
@@ -29,14 +30,15 @@ export function insertPairedTurnOutputInDatabase(
database database
.prepare( .prepare(
`INSERT OR REPLACE INTO paired_turn_outputs `INSERT OR REPLACE INTO paired_turn_outputs
(task_id, turn_number, role, output_text, created_at) (task_id, turn_number, role, output_text, verdict, created_at)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
) )
.run( .run(
taskId, taskId,
turnNumber, turnNumber,
role, role,
outputText.slice(0, MAX_TURN_OUTPUT_CHARS), outputText.slice(0, MAX_TURN_OUTPUT_CHARS),
parseVisibleVerdict(outputText),
createdAt ?? new Date().toISOString(), createdAt ?? new Date().toISOString(),
); );
} }

View File

@@ -24,12 +24,12 @@ import {
} from './message-runtime-rules.js'; } from './message-runtime-rules.js';
import type { ExecuteTurnFn } from './message-runtime-types.js'; import type { ExecuteTurnFn } from './message-runtime-types.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import { resolveStoredVisibleVerdict } from './paired-verdict.js';
import { import {
claimPairedTurnExecution, claimPairedTurnExecution,
type ScheduledPairedFollowUpIntentKind, type ScheduledPairedFollowUpIntentKind,
} from './paired-follow-up-scheduler.js'; } from './paired-follow-up-scheduler.js';
import { hasReviewerLease } from './service-routing.js'; import { hasReviewerLease } from './service-routing.js';
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
import type { import type {
Channel, Channel,
NewMessage, NewMessage,
@@ -128,9 +128,10 @@ export function buildPendingPairedTurn(args: {
const nextTurnAction = resolveNextTurnAction({ const nextTurnAction = resolveNextTurnAction({
taskStatus, taskStatus,
lastTurnOutputRole: lastTurnOutput?.role ?? null, lastTurnOutputRole: lastTurnOutput?.role ?? null,
lastTurnOutputVerdict: lastTurnOutput?.output_text lastTurnOutputVerdict: resolveStoredVisibleVerdict({
? parseVisibleVerdict(lastTurnOutput.output_text) verdict: lastTurnOutput?.verdict ?? null,
: null, outputText: lastTurnOutput?.output_text ?? null,
}),
}); });
const recentMessages = getRecentChatMessages(chatJid, 20); const recentMessages = getRecentChatMessages(chatJid, 20);
const lastHumanMessage = getLastHumanMessageContent(chatJid); const lastHumanMessage = getLastHumanMessageContent(chatJid);

View File

@@ -1,8 +1,8 @@
import { getPairedTurnOutputs } from './db.js'; import { getPairedTurnOutputs } from './db.js';
import { import {
parseVisibleVerdict,
type VisibleVerdict, type VisibleVerdict,
} from './paired-execution-context-shared.js'; } from './paired-execution-context-shared.js';
import { resolveStoredVisibleVerdict } from './paired-verdict.js';
import { import {
matchesExpectedPairedFollowUpIntent, matchesExpectedPairedFollowUpIntent,
resolveFollowUpDispatch, resolveFollowUpDispatch,
@@ -55,9 +55,11 @@ export function resolveLatestPairedTurnOutputContext(args: {
: null; : null;
return { return {
role: latestOutput?.role ?? args.fallbackLastTurnOutputRole ?? null, role: latestOutput?.role ?? args.fallbackLastTurnOutputRole ?? null,
verdict: latestOutput?.output_text verdict:
? parseVisibleVerdict(latestOutput.output_text) resolveStoredVisibleVerdict({
: (args.fallbackLastTurnOutputVerdict ?? null), verdict: latestOutput?.verdict ?? null,
outputText: latestOutput?.output_text ?? null,
}) ?? args.fallbackLastTurnOutputVerdict ?? null,
}; };
} }

View File

@@ -11,7 +11,7 @@ import {
isBotOnlyPairedRoomTurn, isBotOnlyPairedRoomTurn,
} from './message-runtime-flow.js'; } from './message-runtime-flow.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import { parseVisibleVerdict } from './paired-execution-context-shared.js'; import { resolveStoredVisibleVerdict } from './paired-verdict.js';
import { import {
advanceLastAgentCursor, advanceLastAgentCursor,
resolveActiveRole, resolveActiveRole,
@@ -185,9 +185,10 @@ export async function runQueuedGroupTurn(args: {
? (turnOutputs.at(-1)?.role ?? null) ? (turnOutputs.at(-1)?.role ?? null)
: null; : null;
const lastTurnOutputVerdict = currentTask const lastTurnOutputVerdict = currentTask
? turnOutputs.at(-1)?.output_text ? resolveStoredVisibleVerdict({
? parseVisibleVerdict(turnOutputs.at(-1)?.output_text) verdict: turnOutputs.at(-1)?.verdict ?? null,
: null outputText: turnOutputs.at(-1)?.output_text ?? null,
})
: null; : null;
const turnRole = currentTask const turnRole = currentTask
? hasHumanMsg ? hasHumanMsg

View File

@@ -842,6 +842,127 @@ describe('createMessageRuntime', () => {
); );
}); });
it('suppresses a stale owner work item when a new human message arrives while the paired task is still active', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const enqueueMessageCheck = vi.fn();
const activeTask = {
id: 'task-active-owner-follow-up',
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:05.000Z',
} as any;
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getOpenWorkItem).mockReturnValue({
id: 109,
group_folder: group.folder,
chat_jid: chatJid,
agent_type: 'codex',
service_id: 'claude',
delivery_role: 'owner',
status: 'delivery_retry',
start_seq: 1,
end_seq: 1,
result_payload: '이전 step 결과입니다.',
delivery_attempts: 1,
created_at: '2026-03-30T00:00:05.000Z',
updated_at: '2026-03-30T00:00:05.000Z',
delivered_at: null,
delivery_message_id: null,
last_error: 'discord send failed',
});
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-2',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: '이전 답 말고 이 방향으로 진행해줘',
timestamp: '2026-03-30T00:00:10.000Z',
seq: 2,
},
]);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(activeTask);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toContain('이 방향으로 진행해줘');
expect(input.prompt).not.toContain('이전 step 결과입니다.');
await onOutput?.({
status: 'success',
phase: 'final',
result: 'STEP_DONE\n새 입력 기준으로 계속 진행',
output: {
visibility: 'public',
text: 'STEP_DONE\n새 입력 기준으로 계속 진행',
},
} as any);
return {
status: 'success',
result: 'STEP_DONE\n새 입력 기준으로 계속 진행',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-suppress-stale-active-owner-work-item',
reason: 'messages',
});
expect(result).toBe(true);
expect(db.markWorkItemDelivered).toHaveBeenCalledWith(109);
expect(channel.sendMessage).not.toHaveBeenCalledWith(
chatJid,
'이전 step 결과입니다.',
);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
chatJid,
workItemId: 109,
taskId: 'task-active-owner-follow-up',
taskStatus: 'active',
}),
'Suppressed stale owner delivery retry because a new human message arrived while the paired task was still active',
);
});
it('suppresses duplicate stale work item delivery and logs the suppression reason', async () => { it('suppresses duplicate stale work item delivery and logs the suppression reason', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');

View File

@@ -56,6 +56,7 @@ import {
} from './types.js'; } from './types.js';
import { createScopedLogger, logger } from './logger.js'; import { createScopedLogger, logger } from './logger.js';
import { hasReviewerLease } from './service-routing.js'; import { hasReviewerLease } from './service-routing.js';
import type { WorkItem } from './db/work-items.js';
export { export {
resolveHandoffCursorKey, resolveHandoffCursorKey,
resolveHandoffRoleOverride, resolveHandoffRoleOverride,
@@ -113,6 +114,54 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
deps.queue, deps.queue,
); );
const getFreshHumanPreflightMessages = (
chatJid: string,
channel: Channel,
): NewMessage[] => {
const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0';
const preflightRawMessages = getMessagesSinceSeq(
chatJid,
sinceSeqCursor,
deps.assistantName,
);
const preflightMessages = filterLoopingPairedBotMessages(
chatJid,
getProcessableMessages(chatJid, preflightRawMessages, channel),
FAILURE_FINAL_TEXT,
);
return preflightMessages.filter(
(message) => message.is_from_me !== true && !message.is_bot_message,
);
};
const hasHumanMessageAfterWorkItem = (
openWorkItem: WorkItem,
freshHumanMessages: NewMessage[],
): boolean => {
if (freshHumanMessages.length === 0) {
return false;
}
const workItemSeq = openWorkItem.end_seq ?? openWorkItem.start_seq ?? null;
const workItemUpdatedAt = Date.parse(openWorkItem.updated_at);
return freshHumanMessages.some((message) => {
if (message.seq != null && workItemSeq != null) {
return message.seq > workItemSeq;
}
const messageTimestamp = Date.parse(message.timestamp);
if (
Number.isFinite(messageTimestamp) &&
Number.isFinite(workItemUpdatedAt)
) {
return messageTimestamp > workItemUpdatedAt;
}
return true;
});
};
const scheduleQueuedPairedFollowUp = (args: { const scheduleQueuedPairedFollowUp = (args: {
chatJid: string; chatJid: string;
runId: string; runId: string;
@@ -351,25 +400,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
? getLatestOpenPairedTaskForChat(chatJid) ? getLatestOpenPairedTaskForChat(chatJid)
: null; : null;
let openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE); let openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE);
if (openWorkItem?.delivery_role === 'owner' && pendingTask) {
const freshHumanMessages = getFreshHumanPreflightMessages(chatJid, channel);
if ( if (
pendingTask?.status === 'merge_ready' && pendingTask.status === 'merge_ready' &&
openWorkItem?.delivery_role === 'owner' freshHumanMessages.length > 0
) { ) {
const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0';
const preflightRawMessages = getMessagesSinceSeq(
chatJid,
sinceSeqCursor,
deps.assistantName,
);
const preflightMessages = filterLoopingPairedBotMessages(
chatJid,
getProcessableMessages(chatJid, preflightRawMessages, channel),
FAILURE_FINAL_TEXT,
);
const hasFreshHumanMessage = preflightMessages.some(
(message) => message.is_from_me !== true && !message.is_bot_message,
);
if (hasFreshHumanMessage) {
const resolvedTask = resolveOwnerTaskForHumanMessage({ const resolvedTask = resolveOwnerTaskForHumanMessage({
group, group,
chatJid, chatJid,
@@ -389,6 +425,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
); );
openWorkItem = undefined; openWorkItem = undefined;
} }
} else if (
pendingTask.status === 'active' &&
hasHumanMessageAfterWorkItem(openWorkItem, freshHumanMessages)
) {
markWorkItemDelivered(openWorkItem.id);
log.info(
{
chatJid,
workItemId: openWorkItem.id,
taskId: pendingTask.id,
taskStatus: pendingTask.status,
workItemEndSeq: openWorkItem.end_seq ?? null,
freshHumanMessageCount: freshHumanMessages.length,
},
'Suppressed stale owner delivery retry because a new human message arrived while the paired task was still active',
);
openWorkItem = undefined;
} }
} }
const openWorkItemOutcome = await processOpenWorkItemDelivery({ const openWorkItemOutcome = await processOpenWorkItemDelivery({

View File

@@ -22,6 +22,8 @@ import type { PairedTask } from './types.js';
type OwnerFinalizeOutcome = 'stop' | 're_review' | 'continue_owner'; type OwnerFinalizeOutcome = 'stop' | 're_review' | 'continue_owner';
const OWNER_FAILURE_ESCALATION_THRESHOLD = 2; const OWNER_FAILURE_ESCALATION_THRESHOLD = 2;
const EMPTY_STEP_DONE_THRESHOLD = 2;
const OWNER_STEP_DONE_LOOP_THRESHOLD = 3;
export function handleFailedOwnerExecution(args: { export function handleFailedOwnerExecution(args: {
task: PairedTask; task: PairedTask;
@@ -101,6 +103,14 @@ function handleOwnerFinalizeCompletion(args: {
const hasNewChanges = workspace?.workspace_dir const hasNewChanges = workspace?.workspace_dir
? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref) ? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref)
: null; : null;
const nextFinalizeStepDoneCount =
ownerVerdict === 'step_done'
? (task.finalize_step_done_count ?? 0) + 1
: task.finalize_step_done_count ?? 0;
const nextEmptyStepDoneStreak =
ownerVerdict === 'step_done' && hasNewChanges === false
? (task.empty_step_done_streak ?? 0) + 1
: 0;
const signal = resolveOwnerCompletionSignal({ const signal = resolveOwnerCompletionSignal({
phase: 'finalize', phase: 'finalize',
visibleVerdict: ownerVerdict, visibleVerdict: ownerVerdict,
@@ -138,6 +148,41 @@ function handleOwnerFinalizeCompletion(args: {
}, },
patch: { patch: {
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: nextFinalizeStepDoneCount,
empty_step_done_streak: nextEmptyStepDoneStreak,
},
});
return 'stop';
}
if (
signal.kind === 'request_owner_continue' &&
hasNewChanges === false &&
nextEmptyStepDoneStreak >= EMPTY_STEP_DONE_THRESHOLD
) {
requestArbiterOrEscalate({
taskId,
currentStatus: task.status,
expectedUpdatedAt: task.updated_at,
now,
arbiterLogMessage:
'Owner repeated STEP_DONE during finalize without code changes — requesting arbiter',
escalateLogMessage:
'Owner repeated STEP_DONE during finalize without code changes — escalating to user',
logContext: {
taskId,
ownerVerdict,
hasNewChanges,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
finalizeStepDoneCount: nextFinalizeStepDoneCount,
summary: summary?.slice(0, 100),
},
patch: {
owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: nextFinalizeStepDoneCount,
empty_step_done_streak: nextEmptyStepDoneStreak,
}, },
}); });
return 'stop'; return 'stop';
@@ -153,6 +198,9 @@ function handleOwnerFinalizeCompletion(args: {
updatedAt: now, updatedAt: now,
patch: { patch: {
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: nextFinalizeStepDoneCount,
empty_step_done_streak: 0,
}, },
}); });
} }
@@ -179,12 +227,17 @@ function handleOwnerFinalizeCompletion(args: {
updatedAt: now, updatedAt: now,
patch: { patch: {
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: nextFinalizeStepDoneCount,
empty_step_done_streak: nextEmptyStepDoneStreak,
}, },
}); });
logger.info( logger.info(
{ {
taskId, taskId,
ownerVerdict, ownerVerdict,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
finalizeStepDoneCount: nextFinalizeStepDoneCount,
summary: summary?.slice(0, 100), summary: summary?.slice(0, 100),
}, },
'Owner marked finalize output as an intermediate step — task returned to active without re-review', 'Owner marked finalize output as an intermediate step — task returned to active without re-review',
@@ -201,6 +254,9 @@ function handleOwnerFinalizeCompletion(args: {
patch: { patch: {
completion_reason: 'done', completion_reason: 'done',
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: nextFinalizeStepDoneCount,
empty_step_done_streak: 0,
}, },
}); });
logger.info( logger.info(
@@ -242,6 +298,8 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: {
patch: { patch: {
round_trip_count: task.round_trip_count + 1, round_trip_count: task.round_trip_count + 1,
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0,
empty_step_done_streak: 0,
}, },
}); });
if (hasActiveCiWatcherForChat(task.chat_jid)) { if (hasActiveCiWatcherForChat(task.chat_jid)) {
@@ -287,6 +345,16 @@ export function handleOwnerCompletion(args: {
} }
const ownerVerdict = parseVisibleVerdict(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 nextOwnerStepDoneStreak =
ownerVerdict === 'step_done' ? (task.owner_step_done_streak ?? 0) + 1 : 0;
const nextEmptyStepDoneStreak =
ownerVerdict === 'step_done' && hasNewChanges === false
? (task.empty_step_done_streak ?? 0) + 1
: 0;
const signal = resolveOwnerCompletionSignal({ const signal = resolveOwnerCompletionSignal({
phase: 'normal', phase: 'normal',
visibleVerdict: ownerVerdict, visibleVerdict: ownerVerdict,
@@ -303,10 +371,46 @@ export function handleOwnerCompletion(args: {
logContext: { logContext: {
taskId, taskId,
ownerVerdict, ownerVerdict,
ownerStepDoneStreak: nextOwnerStepDoneStreak,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
summary: summary?.slice(0, 100), summary: summary?.slice(0, 100),
}, },
patch: { patch: {
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: nextOwnerStepDoneStreak,
empty_step_done_streak: nextEmptyStepDoneStreak,
},
});
return;
}
if (
signal.kind === 'request_owner_continue' &&
hasNewChanges === false &&
nextOwnerStepDoneStreak >= OWNER_STEP_DONE_LOOP_THRESHOLD &&
nextEmptyStepDoneStreak >= OWNER_STEP_DONE_LOOP_THRESHOLD
) {
requestArbiterOrEscalate({
taskId,
currentStatus: task.status,
expectedUpdatedAt: task.updated_at,
now,
arbiterLogMessage:
'Owner repeated STEP_DONE in active mode without code changes — requesting arbiter',
escalateLogMessage:
'Owner repeated STEP_DONE in active mode without code changes — escalating to user',
logContext: {
taskId,
ownerVerdict,
hasNewChanges,
ownerStepDoneStreak: nextOwnerStepDoneStreak,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
summary: summary?.slice(0, 100),
},
patch: {
owner_failure_count: 0,
owner_step_done_streak: nextOwnerStepDoneStreak,
empty_step_done_streak: nextEmptyStepDoneStreak,
}, },
}); });
return; return;
@@ -319,15 +423,38 @@ export function handleOwnerCompletion(args: {
updatedAt: now, updatedAt: now,
patch: { patch: {
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: nextOwnerStepDoneStreak,
empty_step_done_streak: nextEmptyStepDoneStreak,
}, },
}); });
logger.info( logger.info(
{ taskId, ownerVerdict, summary: summary?.slice(0, 100) }, {
taskId,
ownerVerdict,
hasNewChanges,
ownerStepDoneStreak: nextOwnerStepDoneStreak,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
summary: summary?.slice(0, 100),
},
'Owner marked the current output as an intermediate completed step — keeping task active for follow-up', 'Owner marked the current output as an intermediate completed step — keeping task active for follow-up',
); );
return; return;
} }
if (
nextOwnerStepDoneStreak !== (task.owner_step_done_streak ?? 0) ||
nextEmptyStepDoneStreak !== (task.empty_step_done_streak ?? 0)
) {
applyPairedTaskPatch({
taskId,
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
owner_step_done_streak: nextOwnerStepDoneStreak,
empty_step_done_streak: nextEmptyStepDoneStreak,
},
});
}
maybeAutoTriggerReviewerAfterOwnerCompletion({ maybeAutoTriggerReviewerAfterOwnerCompletion({
task, task,
taskId, taskId,

View File

@@ -3,17 +3,12 @@ import { execFileSync } from 'child_process';
import { isArbiterEnabled } from './config.js'; import { isArbiterEnabled } from './config.js';
import { updatePairedTaskIfUnchanged } from './db.js'; import { updatePairedTaskIfUnchanged } from './db.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import {
parseVisibleVerdict,
type VisibleVerdict,
} from './paired-verdict.js';
import type { PairedTaskStatus } from './types.js'; import type { PairedTaskStatus } from './types.js';
export type VisibleVerdict =
| 'step_done'
| 'task_done'
| 'done'
| 'done_with_concerns'
| 'blocked'
| 'needs_context'
| 'continue';
export type CompletionSignal = export type CompletionSignal =
| { kind: 'request_reviewer'; resetStatusToActive: boolean } | { kind: 'request_reviewer'; resetStatusToActive: boolean }
| { kind: 'request_owner_finalize' } | { kind: 'request_owner_finalize' }
@@ -23,24 +18,8 @@ export type CompletionSignal =
| { kind: 'complete'; completionReason: 'done' | 'escalated' } | { kind: 'complete'; completionReason: 'done' | 'escalated' }
| { kind: 'preserve_review_ready' }; | { kind: 'preserve_review_ready' };
export function parseVisibleVerdict( export { parseVisibleVerdict };
summary: string | null | undefined, export type { VisibleVerdict };
): VisibleVerdict {
if (!summary) return 'continue';
const cleaned = summary.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
if (!cleaned) return 'continue';
const firstLine = cleaned.split('\n')[0].trim();
if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked';
if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context';
if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done';
if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done';
if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine))
return 'done_with_concerns';
if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done';
if (/^\*{0,2}Approved\.?\*{0,2}/i.test(firstLine)) return 'done';
if (/^\*{0,2}LGTM\*{0,2}/i.test(firstLine)) return 'done';
return 'continue';
}
export function resolveOwnerCompletionSignal(args: { export function resolveOwnerCompletionSignal(args: {
phase: 'normal' | 'finalize'; phase: 'normal' | 'finalize';
@@ -270,6 +249,10 @@ export function transitionPairedTaskStatus(args: {
review_requested_at?: string | null; review_requested_at?: string | null;
round_trip_count?: number; round_trip_count?: number;
owner_failure_count?: number; owner_failure_count?: number;
owner_step_done_streak?: number;
finalize_step_done_count?: number;
task_done_then_user_reopen_count?: number;
empty_step_done_streak?: number;
arbiter_verdict?: string | null; arbiter_verdict?: string | null;
arbiter_requested_at?: string | null; arbiter_requested_at?: string | null;
completion_reason?: string | null; completion_reason?: string | null;
@@ -314,6 +297,10 @@ export function applyPairedTaskPatch(args: {
review_requested_at?: string | null; review_requested_at?: string | null;
round_trip_count?: number; round_trip_count?: number;
owner_failure_count?: number; owner_failure_count?: number;
owner_step_done_streak?: number;
finalize_step_done_count?: number;
task_done_then_user_reopen_count?: number;
empty_step_done_streak?: number;
status?: PairedTaskStatus; status?: PairedTaskStatus;
arbiter_verdict?: string | null; arbiter_verdict?: string | null;
arbiter_requested_at?: string | null; arbiter_requested_at?: string | null;
@@ -355,6 +342,10 @@ export function requestArbiterOrEscalate(args: {
review_requested_at?: string | null; review_requested_at?: string | null;
round_trip_count?: number; round_trip_count?: number;
owner_failure_count?: number; owner_failure_count?: number;
owner_step_done_streak?: number;
finalize_step_done_count?: number;
task_done_then_user_reopen_count?: number;
empty_step_done_streak?: number;
arbiter_verdict?: string | null; arbiter_verdict?: string | null;
arbiter_requested_at?: string | null; arbiter_requested_at?: string | null;
completion_reason?: string | null; completion_reason?: string | null;

View File

@@ -143,6 +143,10 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
review_requested_at: null, review_requested_at: null,
round_trip_count: 0, round_trip_count: 0,
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: 0,
task_done_then_user_reopen_count: 0,
empty_step_done_streak: 0,
status: 'active', status: 'active',
arbiter_verdict: null, arbiter_verdict: null,
arbiter_requested_at: null, arbiter_requested_at: null,
@@ -293,6 +297,62 @@ describe('paired execution context', () => {
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled(); expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
}); });
it('records a quick reopen when a new owner task starts shortly after TASK_DONE completion', () => {
const previousTask = buildPairedTask({
id: 'task-completed',
status: 'completed',
completion_reason: 'done',
updated_at: new Date(Date.now() - 60_000).toISOString(),
task_done_then_user_reopen_count: 0,
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(previousTask);
const result = resolveOwnerTaskForHumanMessage({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
existingTask: null,
});
expect(result.task).not.toBeNull();
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-completed',
expect.objectContaining({
task_done_then_user_reopen_count: 1,
}),
);
});
it('resets active STEP_DONE loop counters when a new human message continues an active task', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
buildPairedTask({
status: 'active',
owner_failure_count: 1,
owner_step_done_streak: 2,
empty_step_done_streak: 2,
}),
);
preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-human-reset-step-done-loop',
roomRoleContext: ownerContext,
hasHumanMessage: true,
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 0,
owner_failure_count: 0,
owner_step_done_streak: 0,
empty_step_done_streak: 0,
}),
);
});
it('uses room role context agent overrides when creating a paired task', () => { it('uses room role context agent overrides when creating a paired task', () => {
preparePairedExecutionContext({ preparePairedExecutionContext({
group, group,
@@ -761,6 +821,7 @@ describe('paired execution context', () => {
'task-1', 'task-1',
expect.objectContaining({ expect.objectContaining({
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 1,
}), }),
); );
expect( expect(
@@ -774,6 +835,43 @@ describe('paired execution context', () => {
); );
}); });
it('requests arbiter when active STEP_DONE repeats without code changes', () => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
const repoDir = createCanonicalRepoWithCommit('active step done loop');
const sourceRef = resolveTreeRef(repoDir);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
source_ref: sourceRef,
owner_step_done_streak: 2,
empty_step_done_streak: 2,
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n요약만 반복되고 코드 변경은 없음',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
owner_step_done_streak: 3,
empty_step_done_streak: 3,
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
});
it('returns merge_ready owner finalize output to active when the owner reports STEP_DONE', () => { it('returns merge_ready owner finalize output to active when the owner reports STEP_DONE', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue( vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({ buildPairedTask({
@@ -794,6 +892,44 @@ describe('paired execution context', () => {
expect.objectContaining({ expect.objectContaining({
status: 'active', status: 'active',
owner_failure_count: 0, owner_failure_count: 0,
finalize_step_done_count: 1,
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
});
it('requests arbiter when finalize STEP_DONE repeats without code changes', () => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
const repoDir = createCanonicalRepoWithCommit('reviewed');
const approvedSourceRef = resolveTreeRef(repoDir);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
source_ref: approvedSourceRef,
empty_step_done_streak: 1,
finalize_step_done_count: 1,
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n아직 남았지만 코드 변경은 없음',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
empty_step_done_streak: 2,
finalize_step_done_count: 2,
}), }),
); );
expect( expect(

View File

@@ -30,6 +30,7 @@ import {
hasActiveCiWatcherForChat, hasActiveCiWatcherForChat,
insertPairedTurnOutput, insertPairedTurnOutput,
releasePairedTaskExecutionLease, releasePairedTaskExecutionLease,
updatePairedTask,
upsertPairedProject, upsertPairedProject,
} from './db.js'; } from './db.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -72,6 +73,8 @@ import type {
} from './types.js'; } from './types.js';
import { resolveRoleAgentPlan } from './role-agent-plan.js'; import { resolveRoleAgentPlan } from './role-agent-plan.js';
const TASK_DONE_REOPEN_WINDOW_MS = 10 * 60_000;
function ensurePairedProject( function ensurePairedProject(
group: RegisteredGroup, group: RegisteredGroup,
chatJid: string, chatJid: string,
@@ -144,6 +147,10 @@ function createActiveTaskForRoom(args: {
review_requested_at: null, review_requested_at: null,
round_trip_count: 0, round_trip_count: 0,
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: 0,
task_done_then_user_reopen_count: 0,
empty_step_done_streak: 0,
status: 'active', status: 'active',
arbiter_verdict: null, arbiter_verdict: null,
arbiter_requested_at: null, arbiter_requested_at: null,
@@ -164,6 +171,39 @@ function createActiveTaskForRoom(args: {
return task; return task;
} }
function maybeRecordTaskDoneReopen(previousTask: PairedTask | null): void {
if (
!previousTask ||
previousTask.status !== 'completed' ||
previousTask.completion_reason !== 'done'
) {
return;
}
const completedAt = Date.parse(previousTask.updated_at);
if (!Number.isFinite(completedAt)) {
return;
}
if (Date.now() - completedAt > TASK_DONE_REOPEN_WINDOW_MS) {
return;
}
updatePairedTask(previousTask.id, {
task_done_then_user_reopen_count:
(previousTask.task_done_then_user_reopen_count ?? 0) + 1,
updated_at: new Date().toISOString(),
});
logger.info(
{
taskId: previousTask.id,
chatJid: previousTask.chat_jid,
reopenCount: (previousTask.task_done_then_user_reopen_count ?? 0) + 1,
completionReason: previousTask.completion_reason,
},
'Recorded paired task reopen shortly after TASK_DONE completion',
);
}
function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void { function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void {
const turnIdentity = buildPairedTurnIdentity({ const turnIdentity = buildPairedTurnIdentity({
taskId: task.id, taskId: task.id,
@@ -253,6 +293,7 @@ export function resolveOwnerTaskForHumanMessage(args: {
args.existingTask ?? getLatestOpenPairedTaskForChat(args.chatJid) ?? null; args.existingTask ?? getLatestOpenPairedTaskForChat(args.chatJid) ?? null;
if (!existing) { if (!existing) {
maybeRecordTaskDoneReopen(getLatestPairedTaskForChat(args.chatJid) ?? null);
return { return {
task: canonicalWorkDir task: canonicalWorkDir
? createActiveTaskForRoom({ ? createActiveTaskForRoom({
@@ -432,7 +473,12 @@ export function preparePairedExecutionContext(args: {
updatedAt: now, updatedAt: now,
patch: { patch: {
...(hasHuman ...(hasHuman
? { round_trip_count: 0, owner_failure_count: 0 } ? {
round_trip_count: 0,
owner_failure_count: 0,
owner_step_done_streak: 0,
empty_step_done_streak: 0,
}
: {}), : {}),
}, },
}); });
@@ -443,7 +489,12 @@ export function preparePairedExecutionContext(args: {
updatedAt: now, updatedAt: now,
patch: { patch: {
...(hasHuman ...(hasHuman
? { round_trip_count: 0, owner_failure_count: 0 } ? {
round_trip_count: 0,
owner_failure_count: 0,
owner_step_done_streak: 0,
empty_step_done_streak: 0,
}
: {}), : {}),
}, },
}); });

41
src/paired-verdict.ts Normal file
View File

@@ -0,0 +1,41 @@
export type VisibleVerdict =
| 'step_done'
| 'task_done'
| 'done'
| 'done_with_concerns'
| 'blocked'
| 'needs_context'
| 'continue';
export function parseVisibleVerdict(
summary: string | null | undefined,
): VisibleVerdict {
if (!summary) return 'continue';
const cleaned = summary.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
if (!cleaned) return 'continue';
const firstLine = cleaned.split('\n')[0].trim();
if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked';
if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine))
return 'needs_context';
if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done';
if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done';
if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine))
return 'done_with_concerns';
if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done';
if (/^\*{0,2}Approved\.?\*{0,2}/i.test(firstLine)) return 'done';
if (/^\*{0,2}LGTM\*{0,2}/i.test(firstLine)) return 'done';
return 'continue';
}
export function resolveStoredVisibleVerdict(args: {
verdict?: VisibleVerdict | null;
outputText?: string | null;
}): VisibleVerdict | null {
if (args.verdict) {
return args.verdict;
}
if (!args.outputText) {
return null;
}
return parseVisibleVerdict(args.outputText);
}

View File

@@ -1,3 +1,5 @@
import type { VisibleVerdict } from './paired-verdict.js';
export interface AgentConfig { export interface AgentConfig {
timeout?: number; // Default: 300000 (5 minutes) timeout?: number; // Default: 300000 (5 minutes)
// Per-group model/effort overrides (take precedence over global env vars) // Per-group model/effort overrides (take precedence over global env vars)
@@ -83,6 +85,10 @@ export interface PairedTask {
review_requested_at: string | null; review_requested_at: string | null;
round_trip_count: number; round_trip_count: number;
owner_failure_count?: number | null; owner_failure_count?: number | null;
owner_step_done_streak?: number | null;
finalize_step_done_count?: number | null;
task_done_then_user_reopen_count?: number | null;
empty_step_done_streak?: number | null;
status: PairedTaskStatus; status: PairedTaskStatus;
arbiter_verdict: string | null; arbiter_verdict: string | null;
arbiter_requested_at: string | null; arbiter_requested_at: string | null;
@@ -97,6 +103,7 @@ export interface PairedTurnOutput {
turn_number: number; turn_number: number;
role: PairedRoomRole; role: PairedRoomRole;
output_text: string; output_text: string;
verdict?: VisibleVerdict | null;
created_at: string; created_at: string;
} }