---
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=<runId>, 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:<runId>`).

### 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` до того, як вони стануть ходами сесії.
