Skip to content

fix: narrow reply-root dedupe to queued reruns#47923

Closed
livingghost wants to merge 3 commits intoopenclaw:mainfrom
livingghost:openclaw-pr3
Closed

fix: narrow reply-root dedupe to queued reruns#47923
livingghost wants to merge 3 commits intoopenclaw:mainfrom
livingghost:openclaw-pr3

Conversation

@livingghost
Copy link
Copy Markdown
Contributor

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: reply-root dedupe was broad enough to suppress fresh inbound turns and to collapse thread roots ahead of explicit reply targets.
  • Why it matters: duplicate suppression should only stop queued reruns of the same causal reply target, not valid new messages or empty/cancelled sends.
  • What changed: queued/followup paths now gate on recent reply-root sends, explicit reply-to ids win over thread roots, and sent-root markers are recorded only after outbound delivery returns a message result.
  • What did NOT change (scope boundary): this PR does not change unrelated extension/plugin CI failures or the general send/queue policy outside reply-root dedupe.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • Fresh inbound messages that share a reply root with an earlier message are no longer dropped solely because a queued rerun was already sent.
  • Duplicate suppression now prefers the explicit reply target over a thread root when both are present.

Security Impact (required)

  • New permissions/capabilities? (Yes/No) No
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) No
  • Data access scope changed? (Yes/No) No
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: Windows 11 locally; targeted tests also run in repo worktree with existing dependencies
  • Runtime/container: Node/Vitest in local worktree
  • Model/provider: N/A
  • Integration/channel (if any): reply routing across Slack/Discord/WhatsApp-style paths
  • Relevant config (redacted): default test config

Steps

  1. Mark a reply root as recently sent.
  2. Deliver a fresh inbound turn that shares that reply root but is not a queued rerun.
  3. Deliver a queued/followup rerun with the same reply root.

Expected

  • Fresh inbound turns still run.
  • Queued/followup reruns are suppressed.
  • Reply roots are only marked sent after a real outbound delivery result exists.

Actual

  • Before this patch, fresh inbound turns could be dropped and thread roots could win over explicit reply targets.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: route-reply sent-root marking, reply-root resolution precedence, followup suppression, deliver lifecycle hook metadata, and agent-runner fresh-inbound behavior.
  • Edge cases checked: empty outbound delivery should not mark a root as sent; explicit ReplyToIdFull should beat RootMessageId.
  • What you did not verify: full repo CI rerun after recreating this PR.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.

Compatibility / Migration

  • Backward compatible? (Yes/No) Yes
  • Config/env changes? (Yes/No) No
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: revert commits 78264ed95 and 07db1c8ee from openclaw-pr3
  • Files/config to restore: reply dedupe files under src/auto-reply/reply/ and outbound hook metadata wiring
  • Known bad symptoms reviewers should watch for: fresh inbound replies getting dropped again, or duplicate queued reruns reappearing

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk: reply-root dedupe could still be too broad on providers that only expose thread roots.
    • Mitigation: precedence now prefers explicit reply targets first, and tests cover the fallback behavior separately.

Track recent reply roots across queued and routed sends so the same inbound thread does not trigger duplicate agent replies. Surface the reply-root key into message hooks so plugin-side send guards can keep using the same identifier.
Avoid dropping fresh inbound messages that happen to share a reply root, prefer explicit reply targets over thread roots, and only mark a reply root as sent after an outbound delivery actually occurs.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 16, 2026

Greptile Summary

This PR narrows reply-root deduplication to only suppress queued reruns of the same causal reply target, fixing a regression where fresh inbound turns sharing a reply root with an earlier message were incorrectly dropped.

Key changes:

  • Introduces reply-root-dedupe.ts with a precedence-ordered resolveReplyRootId function (replyToIdFullreplyToIdrootMessageId → message ID fallback) and a TTL-backed global cache for recently-sent reply roots.
  • agent-runner.ts and followup-runner.ts both gate their queued/followup paths on hasRecentSentReplyRoot, leaving fresh inbound runs untouched.
  • route-reply.ts only calls markRecentSentReplyRoot when deliverOutboundPayloads returns at least one result, preventing premature or phantom sent-root markers on empty/cancelled deliveries.
  • enqueue.ts upgrades its dedupe identity to prefer replyRootId over messageId, with a kind-tagged key to prevent cross-namespace collisions.
  • hookMetadata is plumbed through to both message_sending and message_sent lifecycle hooks, giving plugins visibility into reply-root context.

One style concern: in deliver.ts applyMessageSendingHook, ...params.hookMetadata is spread after the canonical channel, accountId, and mediaUrls fields. Because hookMetadata is Record<string, unknown>, a future caller that accidentally includes any of those three keys would silently override the correct delivery-context values exposed to message_sending plugins. Reversing the spread order (spread first, fixed values last) would harden this against future regressions.

Confidence Score: 4/5

  • Safe to merge with one minor style fix recommended in deliver.ts
  • The core dedupe logic is well-structured, all new paths are gated correctly, and the "mark only after confirmed delivery" invariant is correctly implemented. Test coverage spans the key scenarios (fresh inbound not dropped, followup suppressed, empty delivery not marked, hook metadata forwarded). The one concern — spread order in applyMessageSendingHook — is not a bug with current callers but creates a latent override risk for future hookMetadata additions.
  • src/infra/outbound/deliver.ts — review the hookMetadata spread order in applyMessageSendingHook (line 440)
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/infra/outbound/deliver.ts
Line: 437-441

Comment:
**`hookMetadata` spread can silently override canonical delivery fields**

`...params.hookMetadata` is placed *after* `channel`, `accountId`, and `mediaUrls`. Since `hookMetadata` is typed as `Record<string, unknown>`, any future caller that accidentally includes a `channel`, `accountId`, or `mediaUrls` key in the metadata would silently override the canonical delivery values that plugins receive in the `message_sending` hook — without any compile-time warning.

The current callers from `route-reply.ts` don't have conflicting keys, but the contract is fragile. Reversing the spread order ensures the canonical delivery values always win:

```suggestion
          metadata: {
            ...params.hookMetadata,
            channel: params.channel,
            accountId: params.accountId,
            mediaUrls: params.payloadSummary.mediaUrls,
          },
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 78264ed

Comment on lines 437 to 441
channel: params.channel,
accountId: params.accountId,
mediaUrls: params.payloadSummary.mediaUrls,
...params.hookMetadata,
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hookMetadata spread can silently override canonical delivery fields

...params.hookMetadata is placed after channel, accountId, and mediaUrls. Since hookMetadata is typed as Record<string, unknown>, any future caller that accidentally includes a channel, accountId, or mediaUrls key in the metadata would silently override the canonical delivery values that plugins receive in the message_sending hook — without any compile-time warning.

The current callers from route-reply.ts don't have conflicting keys, but the contract is fragile. Reversing the spread order ensures the canonical delivery values always win:

Suggested change
channel: params.channel,
accountId: params.accountId,
mediaUrls: params.payloadSummary.mediaUrls,
...params.hookMetadata,
},
metadata: {
...params.hookMetadata,
channel: params.channel,
accountId: params.accountId,
mediaUrls: params.payloadSummary.mediaUrls,
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/deliver.ts
Line: 437-441

Comment:
**`hookMetadata` spread can silently override canonical delivery fields**

`...params.hookMetadata` is placed *after* `channel`, `accountId`, and `mediaUrls`. Since `hookMetadata` is typed as `Record<string, unknown>`, any future caller that accidentally includes a `channel`, `accountId`, or `mediaUrls` key in the metadata would silently override the canonical delivery values that plugins receive in the `message_sending` hook — without any compile-time warning.

The current callers from `route-reply.ts` don't have conflicting keys, but the contract is fragile. Reversing the spread order ensures the canonical delivery values always win:

```suggestion
          metadata: {
            ...params.hookMetadata,
            channel: params.channel,
            accountId: params.accountId,
            mediaUrls: params.payloadSummary.mediaUrls,
          },
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 78264ed959

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +224 to +227
if (hasRecentSentReplyRoot(recentSentReplyRootKey)) {
await touchActiveSessionEntry();
typing.cleanup();
return undefined;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid dropping distinct queued turns by reply-root cache

This guard drops every follow-up while a run is active whenever the reply-root key was seen recently, without checking whether the inbound turn is actually the same message (or even the same reply target). Because resolveReplyRootId can fall back to RootMessageId, providers that only expose a thread root will map multiple legitimate user messages to the same key, so new messages in that thread can be silently discarded for up to the TTL window instead of being queued.

Useful? React with 👍 / 👎.

return buildRecentSentReplyRootKey({
scopeKey: run.run.sessionKey ?? run.run.sessionId,
agentId: run.run.agentId,
channel: run.originatingChannel,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize channel before building reply-root dedupe keys

The follow-up-side key uses run.originatingChannel as-is, but routeReply records sent keys with normalizeChannelId(channel). If inbound context carries a non-canonical channel string (for example different casing), the recorded key and lookup key diverge, so queued reruns won't be suppressed even though the same reply root was just sent.

Useful? React with 👍 / 👎.

Keep queued reply-root suppression scoped to real retry targets, skip thread-root fallbacks, and ensure hook metadata preserves canonical delivery fields.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bcaaaa229e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +24 to +27
const replyRootId = run.replyRootId?.trim();
if (replyRootId && run.replyRootSource !== "thread-root") {
return { kind: "reply-root", value: replyRootId };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prefer message IDs before reply roots in queue dedupe

resolveRunDedupeIdentity now prioritizes replyRootId over messageId, so queued dedupe collapses distinct turns that reply to the same parent message. Since runPreparedReply now populates replyRootId from ReplyToId* first, two different inbound messages in one thread (different provider message IDs) are treated as duplicates under followup/collect queueing and only one is kept. This drops legitimate user input; replyRootId should be a fallback identity only when a stable message ID is missing.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant