Files
openclaw/src/auto-reply/dispatch-dispatcher.ts
Ivan Z b381559184 fix(auto-reply): don't block reply completion on transcript mirror (#99549)
* fix(auto-reply): don't block reply completion on transcript mirror

mirrorTranscriptAfterDispatcherDelivery() opens with
`await dispatcher.waitForIdle()` and is awaited from sendFinalPayload,
i.e. before the reply operation completes. When a follow-up is queued
(queueDepth>=2) the dispatcher cannot go idle until this operation
clears, and the operation cannot clear until sendFinalPayload returns —
a completion<->dispatcher-idle deadlock. The reply operation stays
phase=running and surfaces as stalled_agent_run (recovery=none) until
the ~15-minute stuck-session abort.

Run the transcript mirror as fire-and-forget: it is post-delivery
bookkeeping and does not need to gate completion. The operation
completes immediately, the queued follow-up proceeds, the dispatcher
goes idle, and the detached mirror then records the transcript.

Validated live across three backends (LMStudio openai-completions,
claude-cli, DeepSeek cloud): queueDepth 2 and 3, zero stalls.

* test(auto-reply): regression coverage for detached transcript mirror

Two focused tests on the queueDepth>=2 completion<->dispatcher-idle deadlock:
- final reply completes even when dispatcher.waitForIdle() never resolves
  (pre-fix this awaited-mirror path deadlocked; the test hangs on the old code).
- a rejecting background mirror does not fail the reply (the detached .catch
  contains it instead of surfacing an unhandled rejection).

* fix(auto-reply): settle transcript mirrors after delivery

Co-authored-by: Zaytsev Ivan <ivan@jad.ru>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-03 18:19:58 -07:00

56 lines
1.8 KiB
TypeScript

// Reply dispatcher lifecycle helpers used by auto-reply dispatch paths.
import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js";
type ReplyDispatcherSettledTask = () => Promise<void> | void;
const settledTasksByDispatcher = new WeakMap<ReplyDispatcher, Set<ReplyDispatcherSettledTask>>();
/** Register post-delivery work owned by the dispatcher's settle lifecycle. */
export function registerReplyDispatcherSettledTask(
dispatcher: ReplyDispatcher,
task: ReplyDispatcherSettledTask,
): void {
const tasks = settledTasksByDispatcher.get(dispatcher) ?? new Set<ReplyDispatcherSettledTask>();
tasks.add(task);
settledTasksByDispatcher.set(dispatcher, tasks);
}
async function runReplyDispatcherSettledTasks(dispatcher: ReplyDispatcher): Promise<void> {
const tasks = settledTasksByDispatcher.get(dispatcher);
if (!tasks) {
return;
}
settledTasksByDispatcher.delete(dispatcher);
for (const task of tasks) {
await task();
}
}
/** Mark a dispatcher complete, wait for pending work, then run optional cleanup. */
export async function settleReplyDispatcher(params: {
dispatcher: ReplyDispatcher;
onSettled?: () => void | Promise<void>;
}): Promise<void> {
params.dispatcher.markComplete();
try {
await params.dispatcher.waitForIdle();
await runReplyDispatcherSettledTasks(params.dispatcher);
} finally {
settledTasksByDispatcher.delete(params.dispatcher);
await params.onSettled?.();
}
}
/** Run work with a dispatcher and always drain it before returning or throwing. */
export async function withReplyDispatcher<T>(params: {
dispatcher: ReplyDispatcher;
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}): Promise<T> {
try {
return await params.run();
} finally {
await settleReplyDispatcher(params);
}
}