---
x-i18n:
    generated_at: "2026-04-16T07:17:43Z"
    model: gpt-5.4
    provider: openai
    source_hash: 95e56c5411204363676f002059c942201503e2359515d1a4b409882cc2e04920
    source_path: refactor/async-exec-duplicate-completion-investigation.md
    workflow: 15
---

# التحقيق في التكرار في إكمال التنفيذ غير المتزامن

## النطاق

- الجلسة: `agent:main:telegram:group:-1003774691294:topic:1`
- العرض: تم تسجيل نفس إكمال التنفيذ غير المتزامن للجلسة/التشغيل `keen-nexus` مرتين في LCM على أنه دور مستخدم.
- الهدف: تحديد ما إذا كان هذا على الأرجح حقن جلسة مكررًا أم مجرد إعادة محاولة تسليم صادرة.

## الاستنتاج

الأرجح أن هذا **حقن جلسة مكرر**، وليس مجرد إعادة محاولة تسليم صادرة فقط.

أقوى فجوة على جانب Gateway موجودة في **مسار إكمال تنفيذ Node**:

1. عند انتهاء تنفيذ من جانب Node، يتم إصدار `exec.finished` مع `runId` الكامل.
2. يقوم Gateway في `server-node-events` بتحويل ذلك إلى حدث نظام ويطلب Heartbeat.
3. تقوم عملية Heartbeat بحقن كتلة أحداث النظام المصروفة في موجه الوكيل.
4. يقوم المشغّل المضمّن بحفظ هذا الموجه كدور مستخدم جديد في سجل الجلسة.

إذا وصل نفس `exec.finished` إلى Gateway مرتين لنفس `runId` لأي سبب كان (إعادة تشغيل، تكرار بعد إعادة الاتصال، إعادة إرسال من المصدر، أو مُنتِج مكرر)، فإن OpenClaw لا يملك حاليًا **تحقق idempotency مقيّدًا بـ `runId`/`contextKey`** على هذا المسار. ستتحول النسخة الثانية إلى رسالة مستخدم ثانية بالمحتوى نفسه.

## مسار الشيفرة الدقيق

### 1. المُنتِج: حدث إكمال تنفيذ Node

- `src/node-host/invoke.ts:340-360`
  - تقوم `sendExecFinishedEvent(...)` بإصدار `node.event` مع الحدث `exec.finished`.
  - تتضمن الحمولة `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 })`
  - ويطلب فورًا عملية إيقاظ:
    - `requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }))`

### 3. نقطة ضعف إزالة التكرار في أحداث النظام

- `src/infra/system-events.ts:90-115`
  - تقوم `enqueueSystemEvent(...)` فقط بقمع **النص المكرر المتتالي**:
    - `if (entry.lastText === cleaned) return false`
  - وهي تخزن `contextKey`، لكنها **لا** تستخدم `contextKey` لتحقيق idempotency.
  - بعد التصريف، تتم إعادة تعيين قمع التكرار.

هذا يعني أن إعادة وصول `exec.finished` بنفس `runId` يمكن قبولها مرة أخرى لاحقًا، رغم أن الشيفرة لديها أصلًا مرشح ثابت مناسب لـ idempotency وهو (`exec:<runId>`).

### 4. التعامل مع الإيقاظ ليس هو المسبّب الأساسي للتكرار

- `src/infra/heartbeat-wake.ts:79-117`
  - يتم دمج عمليات الإيقاظ حسب `(agentId, sessionKey)`.
  - طلبات الإيقاظ المكررة للهدف نفسه تنهار إلى إدخال إيقاظ واحد معلّق.

هذا يجعل **التكرار في التعامل مع الإيقاظ وحده** تفسيرًا أضعف من استيعاب الحدث بشكل مكرر.

### 5. يقوم Heartbeat باستهلاك الحدث وتحويله إلى دخل للموجه

- `src/infra/heartbeat-runner.ts:535-574`
  - تقوم مرحلة ما قبل التنفيذ بمعاينة أحداث النظام المعلقة وتصنيف عمليات تشغيل exec-event.
- `src/auto-reply/reply/session-system-events.ts:86-90`
  - تقوم `drainFormattedSystemEvents(...)` بتصريف الطابور للجلسة.
- `src/auto-reply/reply/get-reply-run.ts:400-427`
  - تُضاف كتلة أحداث النظام المصروفة في بداية جسم موجه الوكيل.

### 6. نقطة الحقن في السجل

- `src/agents/pi-embedded-runner/run/attempt.ts:2000-2017`
  - تستدعي `activeSession.prompt(effectivePrompt)` لإرسال الموجه الكامل إلى جلسة PI المضمّنة.
  - هذه هي النقطة التي يصبح فيها الموجه المشتق من الإكمال دور مستخدم محفوظًا.

لذلك، بمجرد إعادة بناء نفس حدث النظام داخل الموجه مرتين، تصبح رسائل المستخدم المكررة في LCM متوقعة.

## لماذا تكون إعادة محاولة التسليم الصادر فقط أقل احتمالًا

يوجد مسار فشل حقيقي في الإرسال الصادر داخل مشغّل Heartbeat:

- `src/infra/heartbeat-runner.ts:1194-1242`
  - يتم توليد الرد أولًا.
  - يحدث التسليم الصادر لاحقًا عبر `deliverOutboundPayloads(...)`.
  - الفشل هناك يعيد `{ status: "failed" }`.

لكن بالنسبة إلى نفس إدخال طابور حدث النظام، فهذا وحده **لا يكفي** لتفسير أدوار المستخدم المكررة:

- `src/auto-reply/reply/session-system-events.ts:86-90`
  - يكون قد تم بالفعل تصريف طابور أحداث النظام قبل التسليم الصادر.

لذلك، فإن إعادة محاولة الإرسال من القناة وحدها لن تعيد إنشاء نفس الحدث الموضوع في الطابور. قد يفسر ذلك غياب/فشل التسليم الخارجي، لكنه لا يفسر بمفرده رسالة مستخدم ثانية متطابقة في الجلسة.

## احتمال ثانوي أقل ثقة

يوجد حلقة إعادة محاولة للتشغيل الكامل في مشغّل الوكيل:

- `src/auto-reply/reply/agent-runner-execution.ts:741-1473`
  - بعض الإخفاقات العابرة قد تعيد محاولة التشغيل بالكامل وتعيد إرسال `commandBody` نفسه.

يمكن أن يؤدي هذا إلى تكرار موجه مستخدم محفوظ **ضمن نفس تنفيذ الرد** إذا كان قد تم إلحاق الموجه بالفعل قبل تحقق شرط إعادة المحاولة.

أصنّف هذا الاحتمال أدنى من احتمال الاستيعاب المكرر لـ `exec.finished` لأن:

- الفاصل الملحوظ كان نحو 51 ثانية، وهذا يبدو أكثر كأنه دور/إيقاظ ثانٍ لا إعادة محاولة داخل العملية نفسها؛
- التقرير يذكر بالفعل إخفاقات متكررة في إرسال الرسائل، ما يشير أكثر إلى دور منفصل لاحق بدلًا من إعادة محاولة فورية في النموذج/بيئة التشغيل.

## فرضية السبب الجذري

الفرضية الأعلى ثقة:

- وصل إكمال `keen-nexus` عبر **مسار حدث تنفيذ Node**.
- تم تسليم `exec.finished` نفسه إلى `server-node-events` مرتين.
- قبل Gateway النسختين لأن `enqueueSystemEvent(...)` لا يزيل التكرار اعتمادًا على `contextKey` / `runId`.
- كل حدث مقبول أدى إلى Heartbeat وتم حقنه كدور مستخدم في سجل PI.

## إصلاح جراحي صغير مقترح

إذا كان المطلوب إصلاحًا، فأصغر تغيير عالي القيمة هو:

- جعل idempotency لأحداث exec/النظام يراعي `contextKey` ضمن نافذة زمنية قصيرة، على الأقل لحالات التكرار المطابق تمامًا لـ `(sessionKey, contextKey, text)`؛
- أو إضافة إزالة تكرار مخصصة في `server-node-events` لـ `exec.finished` تعتمد على `(sessionKey, runId, event kind)`.

سيمنع هذا مباشرةً تكرارات `exec.finished` المعاد إرسالها قبل أن تتحول إلى أدوار داخل الجلسة.
