diff --git a/docs/uk/refactor/async-exec-duplicate-completion-investigation.md b/docs/uk/refactor/async-exec-duplicate-completion-investigation.md new file mode 100644 index 000000000..8d8157f5d --- /dev/null +++ b/docs/uk/refactor/async-exec-duplicate-completion-investigation.md @@ -0,0 +1,132 @@ +--- +x-i18n: + generated_at: "2026-04-15T20:07:47Z" + model: gpt-5.4 + provider: openai + source_hash: 95e56c5411204363676f002059c942201503e2359515d1a4b409882cc2e04920 + source_path: refactor/async-exec-duplicate-completion-investigation.md + workflow: 15 +--- + +# Розслідування дубльованого завершення Async Exec + +## Обсяг + +- Сесія: `agent:main:telegram:group:-1003774691294:topic:1` +- Симптом: те саме асинхронне завершення exec для session/run `keen-nexus` було двічі зафіксоване в LCM як ходи користувача. +- Мета: визначити, що є найімовірнішим — дубльоване інʼєктування сесії чи звичайний повтор вихідної доставки. + +## Висновок + +Найімовірніше, це **дубльоване інʼєктування сесії**, а не просто повтор вихідної доставки. + +Найсильніша прогалина з боку Gateway — у **шляху завершення node exec**: + +1. Завершення exec на боці node емітить `exec.finished` із повним `runId`. +2. Gateway `server-node-events` перетворює це на системну подію й запитує Heartbeat. +3. Запуск Heartbeat інʼєктує злитий блок системних подій у prompt агента. +4. Вбудований runner зберігає цей prompt як новий хід користувача в транскрипті сесії. + +Якщо той самий `exec.finished` з якоїсь причини двічі доходить до gateway для того самого `runId` (replay, дубль після reconnect, повторне надсилання згори, дубльований producer), OpenClaw наразі **не має перевірки ідемпотентності з ключем `runId`/`contextKey`** на цьому шляху. Друга копія стане другим повідомленням користувача з тим самим вмістом. + +## Точний шлях у коді + +### 1. Producer: подія завершення node exec + +- `src/node-host/invoke.ts:340-360` + - `sendExecFinishedEvent(...)` емітить `node.event` з подією `exec.finished`. + - Payload містить `sessionKey` і повний `runId`. + +### 2. Приймання події в Gateway + +- `src/gateway/server-node-events.ts:574-640` + - Обробляє `exec.finished`. + - Будує текст: + - `Exec finished (node=..., id=, code ...)` + - Ставить його в чергу через: + - `enqueueSystemEvent(text, { sessionKey, contextKey: runId ? \`exec:${runId}\` : "exec", trusted: false })` + - Одразу запитує wake: + - `requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }))` + +### 3. Слабке місце дедуплікації системних подій + +- `src/infra/system-events.ts:90-115` + - `enqueueSystemEvent(...)` пригнічує лише **послідовні дублікати тексту**: + - `if (entry.lastText === cleaned) return false` + - Воно зберігає `contextKey`, але **не** використовує `contextKey` для ідемпотентності. + - Після drain пригнічення дублікатів скидається. + +Це означає, що повторно відтворений `exec.finished` із тим самим `runId` може бути знову прийнятий пізніше, хоча код уже мав стабільного кандидата для ідемпотентності (`exec:`). + +### 4. Обробка wake не є основним джерелом дублювання + +- `src/infra/heartbeat-wake.ts:79-117` + - Wake-сповіщення коалесцюються за `(agentId, sessionKey)`. + - Дубльовані запити wake для тієї самої цілі згортаються в один pending wake entry. + +Через це **саме дублювання обробки wake** виглядає слабшим поясненням, ніж дубльоване приймання подій. + +### 5. Heartbeat споживає подію і перетворює її на вхід для prompt + +- `src/infra/heartbeat-runner.ts:535-574` + - Preflight переглядає pending system events і класифікує запуск exec-event. +- `src/auto-reply/reply/session-system-events.ts:86-90` + - `drainFormattedSystemEvents(...)` зливає чергу для сесії. +- `src/auto-reply/reply/get-reply-run.ts:400-427` + - Злитий блок системних подій додається на початок тіла prompt агента. + +### 6. Точка інʼєкції в транскрипт + +- `src/agents/pi-embedded-runner/run/attempt.ts:2000-2017` + - `activeSession.prompt(effectivePrompt)` надсилає повний prompt у вбудовану PI-сесію. + - Саме тут prompt, похідний від завершення, стає збереженим ходом користувача. + +Отже, щойно ту саму системну подію двічі перебудовано в prompt, дубльовані повідомлення користувача в LCM є очікуваними. + +## Чому простий повтор вихідної доставки менш імовірний + +У heartbeat runner справді є шлях вихідної помилки: + +- `src/infra/heartbeat-runner.ts:1194-1242` + - Спочатку генерується відповідь. + - Вихідна доставка відбувається пізніше через `deliverOutboundPayloads(...)`. + - Помилка там повертає `{ status: "failed" }`. + +Однак для того самого запису в черзі системних подій цього **самого по собі недостатньо**, щоб пояснити дубльовані ходи користувача: + +- `src/auto-reply/reply/session-system-events.ts:86-90` + - Черга системних подій уже злита до вихідної доставки. + +Тому сам по собі повтор channel send не відтворив би той самий запис у черзі. Це може пояснити відсутню/невдалу зовнішню доставку, але саме по собі не пояснює друге ідентичне повідомлення користувача в сесії. + +## Другорядна, менш упевнена можливість + +У runner агента є цикл повного повтору запуску: + +- `src/auto-reply/reply/agent-runner-execution.ts:741-1473` + - Деякі тимчасові збої можуть повторити весь запуск і повторно подати той самий `commandBody`. + +Це може продублювати збережений prompt користувача **в межах того самого виконання reply**, якщо prompt уже був доданий до того, як спрацювала умова повтору. + +Я оцінюю це як менш імовірне, ніж дубльоване приймання `exec.finished`, тому що: + +- спостережуваний розрив становив близько 51 секунди, що більше схоже на другий wake/хід, ніж на повтор усередині процесу; +- у звіті вже згадуються повторні збої надсилання повідомлень, а це більше вказує на окремий пізніший хід, ніж на негайний повтор model/runtime. + +## Гіпотеза щодо першопричини + +Гіпотеза з найвищою впевненістю: + +- Завершення `keen-nexus` пройшло через **шлях подій node exec**. +- Той самий `exec.finished` було двічі доставлено до `server-node-events`. +- Gateway прийняв обидва, тому що `enqueueSystemEvent(...)` не виконує дедуплікацію за `contextKey` / `runId`. +- Кожна прийнята подія запускала Heartbeat і інʼєктувалася як хід користувача в PI-транскрипт. + +## Запропоноване маленьке точкове виправлення + +Якщо потрібне виправлення, найменша, але цінна зміна така: + +- зробити так, щоб ідемпотентність exec/system event враховувала `contextKey` протягом короткого горизонту, принаймні для точних повторів `(sessionKey, contextKey, text)`; +- або додати окрему дедуплікацію в `server-node-events` для `exec.finished` з ключем `(sessionKey, runId, kind події)`. + +Це безпосередньо блокувало б повторно відтворені дублікати `exec.finished` до того, як вони стануть ходами сесії.