diff --git a/src/message-runtime-follow-up.ts b/src/message-runtime-follow-up.ts index 5e9ef4e..980a6c9 100644 --- a/src/message-runtime-follow-up.ts +++ b/src/message-runtime-follow-up.ts @@ -21,11 +21,50 @@ export type PairedFollowUpSource = Parameters< typeof resolveFollowUpDispatch >[0]['source']; +type PairedTurnOutputContext = ReturnType[number]; + type PairedFollowUpTaskContext = Pick< PairedTask, 'id' | 'status' | 'round_trip_count' | 'updated_at' | 'owner_failure_count' >; +function timestampMs(value: string | null | undefined): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function isPersistedOutputOlderThanTaskRevision(args: { + output: PairedTurnOutputContext; + task: Partial> | null | undefined; +}): boolean { + const outputCreatedAt = timestampMs(args.output.created_at); + const taskUpdatedAt = timestampMs(args.task?.updated_at); + if (outputCreatedAt == null || taskUpdatedAt == null) { + return false; + } + return outputCreatedAt < taskUpdatedAt; +} + +function shouldPreferFallbackTurnOutputContext(args: { + output: PairedTurnOutputContext | null | undefined; + task: Partial> | null | undefined; + fallbackRole: PairedRoomRole | null | undefined; +}): boolean { + if (!args.output || !args.fallbackRole) { + return false; + } + if (args.output.role === args.fallbackRole) { + return false; + } + return isPersistedOutputOlderThanTaskRevision({ + output: args.output, + task: args.task, + }); +} + export interface PairedFollowUpDecision { taskId: string | null; taskStatus: PairedTaskStatus | null; @@ -49,7 +88,10 @@ export type PairedFollowUpDispatchResult = }); export function resolveLatestPairedTurnOutputContext(args: { - task: Pick | null | undefined; + task: + | (Pick & Partial>) + | null + | undefined; fallbackLastTurnOutputRole?: PairedRoomRole | null; fallbackLastTurnOutputVerdict?: VisibleVerdict | null; }): { @@ -59,12 +101,19 @@ export function resolveLatestPairedTurnOutputContext(args: { const latestOutput = args.task ? getPairedTurnOutputs(args.task.id).at(-1) : null; + const output = shouldPreferFallbackTurnOutputContext({ + output: latestOutput, + task: args.task, + fallbackRole: args.fallbackLastTurnOutputRole, + }) + ? null + : latestOutput; return { - role: latestOutput?.role ?? args.fallbackLastTurnOutputRole ?? null, + role: output?.role ?? args.fallbackLastTurnOutputRole ?? null, verdict: resolveStoredVisibleVerdict({ - verdict: latestOutput?.verdict ?? null, - outputText: latestOutput?.output_text ?? null, + verdict: output?.verdict ?? null, + outputText: output?.output_text ?? null, }) ?? args.fallbackLastTurnOutputVerdict ?? null, @@ -73,7 +122,10 @@ export function resolveLatestPairedTurnOutputContext(args: { export function resolvePairedFollowUpDecision(args: { task: - | Pick + | Pick< + PairedFollowUpTaskContext, + 'id' | 'status' | 'updated_at' | 'owner_failure_count' + > | null | undefined; source: PairedFollowUpSource; diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 7f5922d..f24ca32 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -2727,9 +2727,20 @@ describe('createMessageRuntime', () => { vi.mocked(db.getLatestOpenPairedTaskForChat).mockImplementation( () => pairedTask, ); + vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ + { + id: 1, + task_id: pairedTask.id, + turn_number: 1, + role: 'owner', + output_text: 'STEP_DONE\nowner work ready for review', + created_at: '2026-03-30T00:00:00.500Z', + }, + ] as any); vi.mocked(agentRunner.runAgentProcess).mockImplementation( async (_group, _input, _onProcess, onOutput) => { pairedTask.status = 'active'; + pairedTask.updated_at = '2026-03-30T00:00:01.000Z'; await onOutput?.({ status: 'success', phase: 'final',