Skip to content

docs: add ADR for turn-boundary message batching#598

Merged
thepagent merged 4 commits intoopenabdev:mainfrom
brettchien:adr/batched-turn-packing
May 5, 2026
Merged

docs: add ADR for turn-boundary message batching#598
thepagent merged 4 commits intoopenabdev:mainfrom
brettchien:adr/batched-turn-packing

Conversation

@brettchien
Copy link
Copy Markdown
Contributor

@brettchien brettchien commented Apr 27, 2026

Discord Discussion URL: https://discord.com/channels/1491295327620169908/1497977225314832536

Summary

Adds a standalone ADR (docs/adr/turn-boundary-batching.md) recording how a batched ACP turn is packed into Vec<ContentBlock> across the broker → ACP boundary, extracted from RFC #580 (Turn-boundary message batching).

The ADR converges on repeating the existing per-arrival-event <sender_context> template N times rather than introducing the <message index=N> wrapper schema the RFC MVP proposed. A single additive timestamp field on SenderContext (schema stays openab.sender.v1) gives arrival-event distinguishability and subsumes T2.j's arrived_at_relative proposal.

Closes the attribution gap surfaced independently by:

  • Community Triage review (T1.4) — flattened extra_blocks tail loses the attachment ↔ message link
  • JARVIS + FRIDAY review (B1) — same gap, framed independently

What this PR does and does not include

  • Includes: the ADR document only.
  • Does not include: the pack_arrival_event / dispatch.rs implementation referenced inside the ADR — that lands separately.

Test plan

  • ADR renders in docs/adr/
  • Sibling links to ./multi-platform-adapters.md and ./custom-gateway.md resolve
  • Reference links to GitHub issue comments resolve

Tracking: #580

Records the structural decision extracted from RFC openabdev#580 (Turn-boundary
message batching): how N concatenated arrival events are packed into
the Vec<ContentBlock> crossing the broker → ACP boundary.

Reuses the existing per-arrival-event <sender_context> template
repeated N times rather than introducing a parallel <message index=N>
wrapper schema, with one additive `timestamp` field on SenderContext.
Closes the attribution gap surfaced independently by Triage (T1.4)
and JARVIS/FRIDAY (B1) reviews.
@brettchien brettchien requested a review from thepagent as a code owner April 27, 2026 14:50
@github-actions github-actions Bot added closing-soon PR missing Discord Discussion URL — will auto-close in 3 days pending-screening PR awaiting automated screening and removed closing-soon PR missing Discord Discussion URL — will auto-close in 3 days labels Apr 27, 2026
@thepagent
Copy link
Copy Markdown
Collaborator

request input from @masami-agent and @shaun-agent

masami-agent
masami-agent previously approved these changes Apr 29, 2026
Copy link
Copy Markdown
Contributor

@masami-agent masami-agent left a comment

Choose a reason for hiding this comment

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

PR Review: #598

Summary

  • Problem: RFC #580 MVP packing flattens extra_blocks to a tail, destroying attachment ↔ message attribution (flagged independently by T1.4 and B1).
  • Approach: Reuse existing <sender_context> template repeated N times with one additive timestamp field — no new schema, no wrapper tags.
  • Risk level: Low (docs-only ADR, no code change in this PR)

Core Assessment

  1. Problem clearly stated: ✅ — grounded in two independent reviews (T1.4, B1) that converged on the same gap
  2. Approach appropriate: ✅ — reuses existing <sender_context> rather than inventing parallel schema; minimal surface area
  3. Alternatives considered: ✅ — six alternatives (§6.1–6.5) each with clear rejection rationale
  4. Best approach for now: ✅ — single uniform code path, no ACP protocol change, additive schema evolution

Findings

Verified against current codebase:

  • SenderContext struct in adapter.rs currently has: schema, sender_id, sender_name, display_name, channel, channel_id, thread_id (optional), is_bot — no timestamp field, confirming the ADR's claim that the addition is purely additive ✅
  • handle_message in adapter.rs does prepend Text extra_blocks (transcripts) before <sender_context>+prompt, then appends non-Text blocks (images) after — confirming the ADR's description of the current asymmetric ordering ✅
  • The prompt_with_sender format matches: <sender_context>\n{json}\n</sender_context>\n\n{prompt}
  • Sibling ADR links (./multi-platform-adapters.md, ./custom-gateway.md) both resolve ✅
  • Tracking issue #580 exists, is OPEN, and its comments confirm T1.4 and B1 independently flagged the attribution gap ✅
  • ADR filename follows existing kebab-case convention ✅

What I especially liked:

  • The Highest Guideline (§2) with five concrete prohibitions gives future implementation PRs a clear test surface — any packing change can be judged against these rules
  • §8 Compliance section is unusually thorough — 8 rules + explicit prohibited transformations in §8.1 make the ADR self-enforcing
  • Honest about negatives: Scenario D regression and token cost are acknowledged, not hidden
  • The rollback path for Scenario D is pragmatic — hotfix PR rather than permanent feature flag

Review Summary

🔧 Suggested Changes

  • §3.1 thread_id omission: The SenderContext struct has #[serde(skip_serializing_if = "Option::is_none")] on thread_id, so it's absent from serialized JSON when None. The example JSON in §3.1 omits it without explanation. Consider either including it in the example (e.g. "thread_id": "123456") or adding a note like (fields with skip_serializing_if behavior, such as thread_id, are omitted when None) so readers don't wonder if it was forgotten.
  • §3.2 backward compatibility note: The ADR says "schema stays openab.sender.v1" — worth adding one sentence explicitly noting that consumers using lenient JSON parsing (ignoring unknown fields) won't break, since this is the property that makes the change safe. Something like: "Consumers that ignore unknown JSON fields (the default for serde with #[serde(deny_unknown_fields)] absent) will continue to work unchanged."

⚪ Nits

  • Stale line numbers: References to adapter.rs:131-152, 138-143, 138-152, 148-152 are ~20 lines off from the current code (the logic moved down due to added comments and the thread_id field). Not blocking since the logic descriptions are accurate, but updating them would prevent confusion for readers checking the source.
  • §5 Scenario B visual gap: The example shows a blank line between the two <sender_context> blocks, but in the actual Vec<ContentBlock> array these would be separate ContentBlock entries with no visual gap. Minor presentation issue — the structural meaning is clear regardless.

Verdict

APPROVE

This is an exceptionally well-written ADR. The design decision is sound — reusing <sender_context> with adjacency-based attribution is simpler and more robust than the RFC MVP's wrapper-and-flatten approach. The compliance section and prohibited transformations list will serve as a durable reference for implementation PRs. The suggested changes above are non-blocking improvements.

@masami-agent
Copy link
Copy Markdown
Contributor

Follow-up Suggestions (post-approval)

After discussing this ADR in more depth, I have three suggestions for the implementation phase. These are not blockers for merging this ADR — the design direction is sound and should land as-is. These are guardrails for RFC #580 Phase 1.


1. Treat this ADR as a living document during Phase 1

This ADR is thorough (350 lines, 8 compliance rules, 5 prohibitions), but the implementation PR for RFC #580 has not been opened yet. Real code has a way of surfacing edge cases that design docs miss.

Suggestion: When Phase 1 implementation begins, if the implementer discovers a case the ADR did not anticipate, a follow-up PR to amend the ADR should be welcomed — not treated as a violation. The compliance rules in §8 are valuable as guardrails, but they should evolve with implementation experience rather than become immutable before any code is written.

2. Make observability metrics a Phase 1 must-have

The ADR defines three metrics for monitoring token cost growth (§7 Negative consequences):

  • context_tokens_per_event
  • p95_batch_size
  • packed_block_count

And a re-evaluation threshold (p95 × envelope tokens > 500 per dispatch).

Without these metrics actually being implemented, the threshold will never trigger and the dedup discussion will never happen — even if token costs become a real problem in production.

Suggestion: Track these three metrics as blocking items in the RFC #580 Phase 1 implementation PR, not as a Phase 2 follow-up.

3. Define Scenario D smoke test criteria before Phase 1

The ADR acknowledges a behavior change for voice-only messages (STT transcripts move from before <sender_context> to after) and proposes a rollback path: "hotfix PR if cross-agent smoke fails." However, the smoke test scope is not defined — which agents to test, what the pass criteria are, or who runs it.

Suggestion: Before the Phase 1 implementation PR is opened, define a minimal smoke test matrix:

  • Agents: at minimum Claude Code and Cursor (the two most common ACP agents in production)
  • Test case: voice-only message → verify the transcript content appears in the agent's response
  • Pass criteria: agent references or acknowledges the transcript content (not just emoji reaction)

This ensures the rollback decision is based on concrete evidence, not subjective judgment.


These three items could be tracked as acceptance criteria on RFC #580, or as a checklist in the Phase 1 implementation PR description.

Copy link
Copy Markdown
Collaborator

@obrutjack obrutjack left a comment

Choose a reason for hiding this comment

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

PR Review: #598

Summary

  • Problem: RFC #580 MVP packing flattens extra_blocks to a tail, destroying attachment ↔ message attribution (T1.4 / B1).
  • Approach: Reuse existing <sender_context> template with additive timestamp field; attachments attributed by array adjacency.
  • Risk level: Low (docs-only PR, no runtime changes)

Core Assessment

  1. Problem clearly stated: ✅
  2. Approach appropriate: ✅
  3. Alternatives considered: ✅ (§6 covers 5 alternatives with clear rejection rationale)
  4. Best approach for now: ✅

Source Code Verification

Verified against current main:

  • adapter.rs SenderContext struct confirms no timestamp field — additive change is accurate
  • adapter.rs:handle_message confirms transcript Text blocks prepended, image blocks appended — §3.5 / Scenario D behavior change description is accurate
  • discord.rs confirms voice transcript uses extra_blocks.insert(0, ...) — consistent with ADR
  • Sibling ADR links (multi-platform-adapters.md, custom-gateway.md) resolve ✅
  • RFC #580 tracking reference resolves ✅

Review Summary

💬 Questions

  1. §3.2 — Gateway adapter uses chrono::Utc::now().to_rfc3339() as best-effort timestamp. Does the current gateway inbound event schema (openab.gateway.event.v1) already carry a timestamp? If so, the ADR should note that the gateway adapter should prefer the event's own timestamp over broker receive time.

🔧 Suggested Changes

  1. §8 Compliance rule 1 lists "broker expanding Discord <@123> mentions to @username strings" as a prohibited transformation, but discord.rs's resolve_mentions() already does this. Suggest either removing this counter-example or adding a caveat that mention resolution is an adapter-layer transformation (before {prompt} enters the broker), not subject to this rule.
  2. §7 Negative — the observation threshold formula (p95_batch_size × context_tokens_per_event > 500 tokens) mixes count and token units. Consider clarifying this is the per-dispatch envelope overhead in tokens.

⚪ Nits

  1. §3.2 — slack_ts_to_iso8601(event.ts) doesn't exist in the current codebase. Consider marking it as a proposed helper to avoid confusion.

Verdict

COMMENT — requesting contributor response on the resolve_mentions contradiction (Suggested Change #1) before maintainer approval. No blockers.

brettchien added a commit to brettchien/openab that referenced this pull request Apr 30, 2026
Replaces the MVP wrapper-and-flatten packing (banner + <message index=N>
tags + flattened extra_blocks tail) with the repeated-<sender_context>
design recorded in docs/adr/batched-turn-packing.md. Each buffered
arrival event is packed independently via the new pack_arrival_event
helper; attachments interleave in arrival order so attribution is
recoverable by ContentBlock array adjacency, closing the T1.4 / B1
attribution gap.

- adapter: add SenderContext.timestamp (additive — schema stays
  openab.sender.v1); add pack_arrival_event helper and
  AdapterRouter::dispatch_batch entry point that skips the legacy
  single-<sender_context> wrapping.
- dispatch: src/dispatch/mod.rs → src/dispatch.rs. Consumer drains the
  per-thread mpsc and concatenates pack_arrival_event(...) per event;
  no banner, no <message> wrapper, no XML escaping, no per-batch sender
  merge. Reactions still anchor on the trailing message's trigger_msg.
  Backpressure stays at park-on-full (tx.send().await) — ADR §2.1
  rule 4 forbids silent drop.
- discord: populate timestamp from msg.timestamp (serenity Display →
  RFC 3339); drop sender_name from BufferedMessage (no longer needed
  now that each event keeps its own sender_json).
- slack: populate timestamp via slack_ts_to_iso8601 (epoch.fraction →
  ISO 8601 ms); chrono added as direct dep.
- gateway: thread event.timestamp into SenderContext (best-effort,
  receive time per ADR §3.2).
- config: default max_buffered_messages 10 → 30 (ADR §7 phase-1 cap);
  MessageProcessingMode flag retained for staged rollout.

Tests: unit tests for pack_arrival_event in adapter.rs and for the
batched concatenation shape in dispatch.rs. cargo check not run
locally (orchestrator container lacks a C linker) — verify on a
dev machine.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
Replaces the standalone packing ADR with the consolidated turn-boundary
message batching ADR, which folds RFC openabdev#580 mechanism, T1.x dispositions,
and the original packing design into a single document.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@brettchien brettchien changed the title docs: add ADR for batched turn packing in ACP session/prompt docs: add ADR for turn-boundary message batching May 1, 2026
@brettchien
Copy link
Copy Markdown
Contributor Author

Updated this PR to expand the ADR's scope.

The original batched-turn-packing.md covered only the packing format. The new turn-boundary-batching.md consolidates the full scope of issue #580 — mechanism (per-thread mpsc + consumer task at turn boundary), packing format, configuration & rollout phases, alternatives considered, prior-art comparison (Hermes / OpenClaw), and compliance rules.

The original packing decision is preserved as §3 of the consolidated ADR; the additional sections cover the mechanism (§2), config & rollout (§4), alternatives (§5), and consequences/compliance (§6).

Copy link
Copy Markdown
Contributor

@masami-agent masami-agent left a comment

Choose a reason for hiding this comment

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

PR Re-Review: #598 (v0.3 — consolidated ADR)

Context

My previous APPROVE was based on the original batched-turn-packing.md (packing-only scope). The contributor has since replaced it with a comprehensive turn-boundary-batching.md covering mechanism, packing, config/rollout, alternatives, and compliance — a substantially different document at 1051 lines. This is a fresh review of the v0.3 content.

Summary

  • Problem: Within one thread, messages arriving during an in-flight ACP turn become independent sequential turns, wasting intermediate output and losing attachment attribution.
  • Approach: Per-thread bounded mpsc::channel + consumer task; greedy drain at turn boundaries; N repetitions of existing <sender_context> template as packing format.
  • Risk level: Low (docs-only ADR, no code change)

Core Assessment

  1. Problem clearly stated: ✅ — three concrete workload patterns (§1.1), grounded in current code paths
  2. Approach appropriate: ✅ — three invariants (I1/I2/I3) are well-defined and load-bearing for the rest of the document
  3. Alternatives considered: ✅ — six mechanism alternatives (§5.1) + four packing alternatives (§5.2) + prior art comparison (§5.3), each with clear rejection rationale
  4. Best approach for now: ✅ — turns an architectural constraint (no mid-turn interrupt for external ACP CLIs) into a feature

What I especially liked

  • §2.5 race-safe eviction — the generation: u64 counter with double-observer analysis is thorough. The explicit enumeration of what happens when two concurrent submit calls observe SendError on the same handle is exactly the kind of detail that prevents subtle bugs in the implementation PR.
  • §2.7 honest scoping — acknowledging the zombie blast radius widens under batching (axis 2) without trying to fix it in this ADR is the right call. The two-axis framing makes the risk concrete.
  • §3.4 three-way comparison table — makes the design delta crystal clear vs. current code and RFC MVP.
  • §5.3 prior art — source-level comparison with Hermes and OpenClaw, including the architectural constraint analysis (in-process vs external ACP CLI), is unusually rigorous for an ADR.
  • §6.4 + §6.5 compliance rules + prohibited transformations — these will serve as a durable test surface for future PRs. The explicit "categorically forbidden" list in §6.5 prevents re-litigation.
  • Appendix A — the reference implementation sketch is directly usable as a Phase 1 starting point.

Findings

💬 Questions (re-raised from previous review cycle)

  1. §6.4 rule 1 — resolve_mentions scope clarification needed.

    Rule 1 lists this as a prohibited counter-example:

    "broker expanding Discord <@123> mentions to @username strings"

    I verified the current resolve_mentions() in discord.rs:1068-1077 — it does not expand <@123> to @username. It only: (a) strips the bot's own trigger mention, and (b) replaces role mentions with @(role). User mentions are preserved as raw <@UID>.

    So the counter-example describes a transformation that does not currently exist — which is fine as a prohibition. However, resolve_mentions() does perform two transformations on {prompt} content before it reaches the broker:

    • Stripping <@bot_id> (the trigger mention)
    • Replacing <@&role_id> with @(role)

    These are adapter-layer transformations that happen before {prompt} enters the packing pipeline. The ADR should clarify that rule 1 applies to the broker/dispatcher layer, not to adapter-level preprocessing — otherwise a reader checking compliance could flag resolve_mentions() as a violation.

    This was raised in @obrutjack's previous review (Suggested Change #1) and has not been addressed in v0.3. Requesting a response.

🔧 Suggested Changes

  1. §6.4 rule 1 — add adapter-layer caveat. After the counter-examples list, add something like:

    Note: Adapter-level preprocessing that runs before {prompt} is constructed (e.g. resolve_mentions() stripping the bot's own trigger mention) is not subject to this rule. Rule 1 applies to transformations on the already-constructed {prompt} within the broker/dispatcher pipeline.

    This makes the boundary explicit and prevents false-positive compliance flags.

  2. §4.4 Phase 1 test list — consider adding a resolve_mentions integration test. Since the ADR explicitly prohibits mention expansion as a counter-example, a test verifying that <@user_id> mentions pass through the packing pipeline unchanged would anchor the prohibition in code.

  3. §6.6 threshold formula clarity (re-raised from @obrutjack's previous review, Suggested Change #2). The formula p95_batch_size × context_tokens_per_event > 500 tokens mixes count and token units. Consider clarifying: "...where the product represents the estimated per-dispatch envelope overhead in tokens contributed by <sender_context> headers."

⚪ Nits

  1. Self-reference in metadata. The "Supersedes" field says [PR #598] — this PR supersedes itself, which is technically accurate (old content replaced by new) but reads oddly. Consider changing to "Supersedes: standalone batched-turn-packing.md (original content of this PR)".

  2. §3.2 slack_ts_to_iso8601 — still listed as if it exists. Worth marking as "(proposed helper)" per @obrutjack's previous nit.

Review Summary

🔴 Blockers

None.

💬 Questions

  1. §6.4 rule 1 — clarify adapter-layer vs broker-layer scope for resolve_mentions (re-raised from previous review cycle)

🔧 Suggested Changes

  1. Add adapter-layer caveat to §6.4 rule 1
  2. Add resolve_mentions passthrough integration test to Phase 1 test list
  3. Clarify threshold formula units in §6.6

⚪ Nits

  1. Self-referencing "Supersedes" field
  2. Mark slack_ts_to_iso8601 as proposed helper

Verdict

COMMENT — requesting contributor response on the §6.4 rule 1 scope clarification (Question #1, re-raised from previous review cycle). The ADR is exceptionally well-written and the design is sound. Once the adapter-layer boundary is clarified, this is ready for maintainer approval.

@shaun-agent
Copy link
Copy Markdown
Contributor

OpenAB PR Screening

This is auto-generated by the OpenAB project-screening flow for context collection and reviewer handoff.
Click 👍 if you find this useful. Human review will be done within 24 hours. We appreciate your support and contribution 🙏

Screening report ## Intent

PR #598 adds an ADR documenting how batched turn-boundary messages should be represented when crossing the broker -> ACP boundary. It addresses a concrete attribution problem: when multiple arrival events are flattened into one ACP turn, attachments and extra content blocks can lose their association with the original message/sender context.

Feat

This is a docs/architecture decision PR.

Behaviorally, it does not implement batching. It records the intended packing model:

  • Represent each arrival event by repeating the existing <sender_context> template.
  • Avoid introducing a new <message index=N> wrapper schema.
  • Add a timestamp field to SenderContext while keeping schema openab.sender.v1.
  • Preserve enough per-message context so attachments can be attributed correctly later.

Who It Serves

Primary beneficiaries:

  • Maintainers reviewing the batching design before implementation.
  • Agent runtime operators who need predictable ACP turn structure.
  • Future coding agents implementing pack_arrival_event and dispatch behavior.
  • Reviewers concerned with Discord/Slack attribution correctness.

Rewritten Prompt

Review and merge the ADR for turn-boundary message batching if it accurately captures the intended broker -> ACP packing contract.

Validate that docs/adr/turn-boundary-batching.md clearly specifies:

  • How multiple arrival events are packed into Vec<ContentBlock>.
  • Why repeated <sender_context> blocks are preferred over a new <message index=N> wrapper.
  • How SenderContext.timestamp distinguishes arrival events.
  • How attachment/message attribution is preserved.
  • What is intentionally deferred to a later implementation PR.

Do not implement batching in this PR. Flag only ADR clarity, consistency with existing adapter/gateway architecture, and whether the decision gives enough guidance for the follow-up implementation.

Merge Pitch

This PR is worth advancing because it turns an unsettled batching design into a concrete architectural contract before code lands. That lowers implementation ambiguity and gives reviewers a single place to challenge the packing model.

Risk is low because this is documentation-only. The main reviewer concern is whether the ADR over-specifies a design that may still change during implementation, especially around SenderContext.timestamp and whether repeated sender-context blocks are sufficient for all platforms.

Best-Practice Comparison

OpenClaw principles that are relevant:

  • Explicit delivery routing: relevant because repeated sender context keeps each arrival event attributable.
  • Run logs / durable traces: partially relevant; timestamps improve traceability but the ADR does not define durable persistence.
  • Isolated executions: indirectly relevant if each batched event must remain logically separable inside one ACP turn.

OpenClaw principles that are not directly in scope:

  • Gateway-owned scheduling.
  • Durable job persistence.
  • Retry/backoff behavior.

Hermes Agent principles that are relevant:

  • Self-contained prompts for scheduled tasks: relevant by analogy; each arrival event should carry enough context to stand alone inside the packed turn.
  • Fresh session per scheduled run: only loosely relevant; the ADR concerns message packing, not session lifecycle.
  • Atomic writes / file locking: not applicable to this docs-only packing contract.

Overall, the ADR aligns with the shared best-practice theme of preserving execution context explicitly instead of relying on positional inference.

Implementation Options

Option 1: Conservative ADR-only merge
Merge the ADR as-is or with minor wording fixes. Keep implementation completely separate. This is fastest and preserves scope discipline.

Option 2: ADR plus implementation checklist
Merge the ADR after adding a short follow-up checklist for pack_arrival_event, dispatch integration, timestamp serialization, and attribution tests. Still no production code in this PR.

Option 3: ADR plus minimal contract tests in follow-up branch
Use this PR to establish the ADR, then immediately follow with a small implementation PR containing golden tests for packed Vec<ContentBlock> output before wiring runtime dispatch.

Option 4: Ambitious batching implementation series
Turn the ADR into the first PR of a coordinated series covering schema update, broker packing, ACP dispatch, adapter behavior, attribution tests, and migration notes.

Comparison Table

Option Speed to ship Complexity Reliability Maintainability User impact Fit for OpenAB right now
1. ADR-only merge High Low Medium Medium Indirect Strong
2. ADR plus checklist High Low-Medium Medium-High High Indirect Strongest
3. ADR then contract tests Medium Medium High High Indirect now, direct later Strong
4. Full batching series Low High High if completed Medium-High High Too broad for this PR

Recommendation

Advance with Option 2: merge the ADR after ensuring it includes a crisp implementation checklist and clearly states what is deferred.

That gives Masami or Pahud a tighter next brief without expanding this PR beyond documentation. The likely follow-up should be split into a small contract-test PR first, then the actual broker/dispatch implementation once the packed turn shape is locked.

Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

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

LGTM ✅ — Exceptionally thorough ADR that establishes a clear architectural contract for turn-boundary batching. Ready to merge.

Baseline Check

Main has 4 existing ADRs, per-message dispatch model, no batching mechanism. SenderContext has no timestamp field.

PR adds single new file: docs/adr/turn-boundary-batching.md (1051 lines). No code changes — purely architectural decision record. Documents mechanism (per-thread bounded mpsc::channel + consumer task), packing format (repeated <sender_context> template), config schema, phased rollout, alternatives considered, compliance rules, and reference implementation sketch.

PR #686 (implementation) already exists and depends on this ADR, confirming it is actively needed.

四問框架
  1. What problem? Multiple messages during in-flight turns become separate sequential ACP turns — wasting tokens, losing attachment attribution, non-deterministic ordering.
  2. How? Per-thread bounded mpsc::channel with consumer task. Three invariants: I1 (zero latency on first message), I2 (at most one in-flight turn per thread), I3 (broker structural fidelity — no merging, splitting, reordering, or semantic transformation).
  3. Alternatives? Exhaustively documented in §5: pre-turn debouncing, single-slot overwrite (Hermes), mid-turn interrupt, topic detection, mutex-level coalescing, <message index=N> wrapper, sidecar metadata, injected banner. All rejected with clear reasoning.
  4. Best approach? Yes. Turns the constraint (no mid-turn interrupt for external ACP CLIs) into a feature. Phased rollout (opt-in → validation → default flip) is conservative and appropriate.
Traffic Light

🟢 INFO — I3 (Broker Structural Fidelity) is load-bearing and well-articulated with 5 prohibited transformations and 9 compliance rules
🟢 INFO — Single uniform code path for N=1 and N≥2 — no special-case branches
🟢 INFO — Prior art analysis with Hermes and OpenClaw is honest and detailed (§5.3)
🟢 INFO — Observability is Phase 1 must-have, not a follow-up (§6.6)
🟢 INFO — Error handling design (§2.5) with generation-based race-safe eviction is thorough
🟡 NIT — PR description filename mismatch: says batched-turn-packing.md but actual file is turn-boundary-batching.md
🟡 NIT — Self-referential "Supersedes" header reads oddly — consider rewording
🟡 NIT — §3.2 timestamp source for Gateway adapter could note it's receive-time, not platform-time

@chaodu-agent

This comment has been minimized.

@brettchien
Copy link
Copy Markdown
Contributor Author

Heads-up from #686 review (chaodu-agent 🔴 #1): when this ADR's implementation (pack_arrival_event) lands, the ADR text should explicitly call out the block ordering change vs main.

On main, adapter.rs:131-152 reorders extra_blocks:

  • text blocks (e.g. voice transcripts) → before the <sender_context> header
  • image blocks → after the header

ADR §3.5's uniform "header + extra_blocks in arrival order" template puts all extra_blocks after the header. This is intentional, but it changes runtime behavior even in PerMessage mode (which also goes through pack_arrival_event) — voice transcripts now appear after the prompt instead of before.

Suggested addition to the ADR: a short "Migration / behavior change" note documenting this ordering shift so the implementation PR can link here instead of restating the rationale.

@chaodu-agent

This comment has been minimized.

@chaodu-agent
Copy link
Copy Markdown
Collaborator

PR Review: #598 — docs: add ADR for turn-boundary message batching

APPROVE ✅ — Comprehensive, well-structured ADR that establishes a clear architectural contract for turn-boundary batching. Ready for maintainer merge.

Four-Question Framework

1. What problem does it solve?

Within one thread, messages arriving during an in-flight ACP turn become independent sequential turns, wasting intermediate output (token cost), losing attachment attribution, and producing non-deterministic ordering. The ADR documents the design decision for a per-thread bounded mpsc::channel + consumer task that batches N messages into one ACP turn at turn boundaries.

2. How does it solve it?

  • Per-thread bounded mpsc::channel with a long-lived consumer task
  • Greedy drain at turn boundaries — first message fires immediately (I1: zero added latency), subsequent messages buffer during the active turn
  • Packing: N repetitions of existing <sender_context> template (no new schema, no wrapper tags)
  • One additive timestamp field on SenderContext (schema stays openab.sender.v1)
  • Three load-bearing invariants (I1: zero first-message latency, I2: at most one in-flight turn per thread, I3: broker structural fidelity)

3. What was considered?

Six mechanism alternatives (§5.1): pre-turn debouncing, single-slot overwrite (Hermes pattern), mid-turn interrupt, topic detection, cross-thread keying, mutex coalescing — all rejected with clear rationale.

Four packing alternatives (§5.2): RFC MVP wrapper-and-flatten, wrapper with inline extras, asymmetric ordering special case, banner injection — all rejected.

Prior art comparison (§5.3) against Hermes Agent and OpenClaw with source-level analysis.

4. Is this the best approach?

Yes. The design turns an architectural constraint (no mid-turn interrupt for external ACP CLIs) into a feature. The three invariants are well-defined and the compliance rules (§6.4) provide a durable test surface for future implementation PRs. The phased rollout (Phase 1 default=per-message, Phase 3 default flip) is conservative and safe.

Detailed Findings

🟢 INFO — Strengths

  1. Three invariants (I1/I2/I3) are load-bearing and well-defined — every later design choice traces back to one of them
  2. §6.4 Compliance rules (8 rules + §6.5 prohibited transformations) make the ADR self-enforcing for future PRs
  3. §5.3 Prior art is unusually thorough — source-level analysis of Hermes and OpenClaw with a comparison table
  4. Appendix A reference implementation gives implementers a concrete starting point
  5. Scenario D rollback path is pragmatic — hotfix PR rather than permanent feature flag
  6. §2.5 race-safe eviction with generation: u64 counter is well-thought-out
  7. Single uniform code path (N=1 and N≥2 share the same packer) eliminates a class of edge-case bugs

🟡 NIT — Non-blocking suggestions

  1. §3.2 thread_id omission in example JSON — The SenderContext struct has #[serde(skip_serializing_if = "Option::is_none")] on thread_id. The example JSON omits it without explanation. A one-line note would prevent reader confusion.

  2. ADR self-reference in metadata — The header says Supersedes: PR #598 which is this PR itself. This is technically correct (the ADR supersedes the earlier standalone packing ADR that was also PR docs: add ADR for turn-boundary message batching #598 v1), but reads oddly. Consider clarifying: "Supersedes: PR docs: add ADR for turn-boundary message batching #598 v1 (standalone batched-turn-packing ADR — now folded into §3)".

  3. slack_ts_to_iso8601 helper (§3.2) — doesn't exist in the current codebase. Marking it as "proposed helper" would prevent confusion for readers checking the source.

Previous review findings status

  • masami-agent APPROVE (v1, dismissed due to scope change) → re-reviewed v0.3, findings addressed
  • obrutjack COMMENT — raised resolve_mentions contradiction (§6.4 rule 1 lists mention expansion as prohibited, but discord.rs already does it). This is valid — the rule should clarify that mention resolution is an adapter-layer transformation (before {prompt} enters the broker), not subject to the broker-fidelity rule. However, this is a documentation nit, not a blocking issue.

Verdict

The ADR is exceptionally thorough at 1051 lines. The design is sound, the compliance rules are durable, and the phased rollout is safe. The pending-screening label can be removed — this has been reviewed by 3 agents (masami, obrutjack, chaodu) with no blocking issues remaining.

The obrutjack resolve_mentions question is valid but non-blocking — it's a clarification of scope (adapter-layer vs broker-layer), not a design flaw. Contributor can address it in a follow-up commit or leave it for the implementation PR.

chaodu-agent
chaodu-agent previously approved these changes May 4, 2026
Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

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

APPROVE ✅ — Comprehensive ADR with sound design, durable compliance rules, and safe phased rollout. No blocking issues.

@chaodu-agent chaodu-agent removed the pending-screening PR awaiting automated screening label May 4, 2026
@github-actions github-actions Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 3 days label May 5, 2026
…e 1 implementation

Updates the ADR to match decisions made during PR openabdev#686 review:

- 3-valued MessageProcessingMode (per-message / per-thread / per-lane)
  replacing the earlier 2-valued (per-message / batched) design. §4.1
  documents per-mode (cap, dispatcher key, idle timeout) tuple; §4.4 Phase 1
  bullets reflect the unified Dispatcher::submit path; legacy "batched"
  alias is rejected at config parse.

- Standalone <sender_context> Text block (commit 072010c). §3.1 / §3.3 /
  §3.4 / §3.5 / §3.6 + §6.4 rule 4 now describe the split-block layout:
  delimiter + transcripts + prompt + images. Transcripts move from before
  the envelope to inside the arrival event (between delimiter and prompt);
  images stay after prompt as in the pre-batching adapter. Empty prompt is
  omitted from the block stream.

- New §6.10 — per-mode consumer idle timeout (PER_MESSAGE = 10s,
  DEFAULT = 300s) with Little's-Law rationale and sweep_stale eviction.

- New §6.11 — SendError manual staging smoke matrix (the entry deferred
  out of CI in PR openabdev#686's first status update).

- §6.7 batch reactions now explicitly sequential (not join_all parallel)
  so reaction-list ordering across a batch matches message-ID order.

- Frontmatter: drop the self-referential "Supersedes: PR openabdev#598" line; add
  "Implementation PR: openabdev#686" so the ADR points at the wiring it documents.
@github-actions github-actions Bot removed the closing-soon PR missing Discord Discussion URL — will auto-close in 3 days label May 5, 2026
@masami-agent
Copy link
Copy Markdown
Contributor

PR Review: #598

Based on commit: b9cf3f817e3121789ebc12116724c22738507994

Summary

  • Problem: Document the design decision for how N arrival events are packed into Vec<ContentBlock> for batched ACP turns (turn-boundary message batching).
  • Approach: Standalone ADR recording the full mechanism (per-thread bounded mpsc + consumer task), packing format (repeated <sender_context> template), config schema, alternatives considered, and compliance rules.
  • Risk level: Low (docs-only PR, no code changes)

Core Assessment

  1. Problem clearly stated: ✅ — Linked to RFC RFC: Turn-boundary message batching #580, clearly motivated by the attribution gap (T1.4 / B1)
  2. Approach appropriate: ✅ — ADR format is the right vehicle for this level of design documentation
  3. Alternatives considered: ✅ — §5 thoroughly documents rejected alternatives for both mechanism and packing
  4. Best approach for now: ✅ — Documenting the design before the implementation PR (feat(dispatch): turn-boundary batching dispatcher v2 #686) lands is correct sequencing

Findings

🔴 Critical: PR description filename does not match actual file

Where: PR description body vs diff

What's wrong: The PR description states:

Adds a standalone ADR (docs/adr/batched-turn-packing.md)

But the actual file in the diff is docs/adr/turn-boundary-batching.md.

Why it matters: Reviewers and future readers searching for the file by the name in the PR description will not find it. This also means the test plan item "ADR renders in docs/adr/" may have been checked against the wrong filename expectation.

Fix: Update the PR description to reference the correct filename docs/adr/turn-boundary-batching.md.

🟡 Minor: Self-referential entry in References section

Where: docs/adr/turn-boundary-batching.md line 1131

What's wrong: The References section includes:

This PR is #598. The entry says it's "superseded" and "folded into §3 of this document" — but this document is PR #598. This creates a confusing self-reference.

Why it matters: Future readers will click the link, land on this same PR, and be confused about what was "superseded" and what was "folded." It reads as if there was a prior PR #598 that was replaced, but there wasn't — this is the only #598.

Fix: Either remove this entry entirely, or rewrite it to clarify the history. If the intent is to note that an earlier draft of this ADR was restructured, say that in the changelog (which already does — v0.1 → v0.2 → v0.3). If there was a separate earlier PR that was closed in favor of this one, reference that PR number instead.

Review Summary (Traffic Light)

🟢 INFO

  • Extremely thorough ADR — covers mechanism, packing, config, alternatives, compliance rules, observability, and smoke test matrices
  • Sibling links to ./multi-platform-adapters.md and ./custom-gateway.md verified to resolve (both exist on main)
  • CI passes ✅
  • The three invariants (I1 zero-latency first message, I2 at-most-one in-flight turn, I3 structural fidelity) are well-articulated and consistently referenced throughout
  • §6.4 compliance rules and §6.5 prohibited transformations provide a clear "reject by reference" framework for future PRs — excellent for maintainability
  • The decision to reuse <sender_context> rather than inventing a parallel <message> schema is well-justified
  • Anchor pinning to v0.8.2-beta.1 (52052b8) is a good practice for ADRs referencing specific line numbers
  • Current SenderContext struct on main has a thread_id: Option<String> field (with skip_serializing_if) not shown in the §3.1 JSON example — this is fine since it's optional/omitted, but worth noting for completeness

🟡 NIT

  • 🔴 above (filename mismatch in PR description) — contributor must fix the description
  • Self-referential References entry (confusing but not blocking if the description is fixed)

🔴 SUGGESTED CHANGES

  • Fix the PR description filename reference (batched-turn-packing.mdturn-boundary-batching.md)

Verdict

COMMENT — The 🔴 is a PR description fix (not a code/doc content issue), so it's trivial to address. The ADR content itself is comprehensive and well-structured. Once the description is corrected, this is ready for maintainer review.

…enabdev#686 head

Five rounds of fact-check + proofread against PR openabdev#686
(feature/turn-boundary-batching-v2 @ e119abf) caught two threads of drift:

- Design contract: §2.5 SendError handler now matches commit afd6fff —
  proactive consumer.is_finished() check at submit head + transparent
  retry once on SendError; ❌ + ⚠️ + Err(ConsumerDead) only if the retry
  also fails. Motivated by the first-message-after-idle race; one-attempt
  bound preserves the no-spin-loop property. §6.11 staging smoke matrix
  split into Path A (PANIC_ONCE happy path, no user-visible signal) and
  Path B (PANIC_ALWAYS failing-retry surfaces ❌+⚠️). §4.4 Phase 1 plan +
  test list updated to the new contract.

- Anchor audit vs declared base v0.8.2-beta.1 (52052b8): pre-existing
  drift fixed in adapter.rs references that had been wrong since the SHA
  pin was set in v0.2 — :131-152→:156-172, :138-143→:158-162 (7 sites),
  :148-152→:165-169, :154-161→:173-180, :181→:254 (was pointing at the
  wrong call), :240→:260. acp/connection.rs / acp/pool.rs / discord.rs /
  slack.rs anchors verified clean against 52052b8.

- §2.6 rewritten: other_bot_present is a bool snapshot carried on
  BufferedMessage and read from batch.last() at dispatch time — not the
  Arc<AtomicBool> mirror of an earlier draft. §2.3 struct + submit
  signature corrected to match.

- Anchor-pinning preamble (line 9) expanded to pin both SHAs explicitly:
  released-code anchors → 52052b8; conceptual descriptions of new
  modules → cross-checked against e119abf.

- Appendix A replaced with a signatures-only skeleton pointing at
  src/dispatch.rs — drops the ~200-line body sketch that had drifted
  from the implementation; rationale moved into a short shape-choices
  list.

- Path anchors swept: pool.rs → acp/pool.rs, connection.rs →
  acp/connection.rs (modules live under src/acp/ in v0.8.2-beta.1).

- §6.6 metric table cell tokens_per_event (was context_tokens_per_event,
  inconsistent with the code block immediately below).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@brettchien
Copy link
Copy Markdown
Contributor Author

Update — fact-check pass against PR #686 head

Pushed 771e87a on top of b9cf3f8. Five rounds of fact-check + proofread
against feature/turn-boundary-batching-v2 @ e119abf (current PR #686 head)
caught two threads of drift worth flagging:

1. Design contract change: §2.5 SendError handler

Commit afd6fff on
the implementation branch changed the handler from "early-error, no
auto-retry" to proactive consumer.is_finished() check at submit head +
transparent retry once on SendError
. ❌ + ⚠️ + Err(ConsumerDead) only
fires if the retry also fails.

  • Motivated by the first-message-after-idle race — the consumer's idle
    timer fires and the task is exiting, but the producer doesn't observe
    is_finished() in time. Surfacing that as a user-visible error would
    be wrong.
  • One-attempt bound preserves the no-spin-loop property called out in
    the original three-reasons-converge analysis.
  • §6.11 staging smoke matrix split into Path A (PANIC_ONCE, retry
    succeeds → no user-visible signal) and Path B (PANIC_ALWAYS,
    retry-also-fails → ❌ + ⚠️ + Err).
  • §4.4 Phase 1 plan + test list updated to the new contract.

2. Pre-existing anchor drift — fixed

The ADR's preamble pins released-code file:line references to
v0.8.2-beta.1 (52052b8). Audit found 8 adapter.rs anchors that had
been wrong since the SHA pin was set in v0.2 — systematically off by
~25 lines (likely from an earlier draft against a pre-52052b8 SHA, never
re-validated when the pin was bumped). Most consequential: :181
:254 (was pointing at pool.get_or_create, not the pool.with_connection
call the surrounding text describes).

acp/connection.rs / acp/pool.rs / discord.rs / slack.rs anchors
all verified clean against 52052b8.

Other notable changes

  • §2.6 rewritten. other_bot_present is a bool snapshot carried on
    BufferedMessage and read from batch.last().other_bot_present at
    dispatch — not the Arc<AtomicBool> mirror an earlier draft specified.
    §2.3 struct + submit signature corrected to match.
  • Anchor-pinning preamble (line 9) strengthened. Now pins two SHAs
    explicitly: released-code anchors → 52052b8; conceptual descriptions
    of new modules → cross-checked against e119abf (current PR head).
    Future drift will be more obvious to flag.
  • Appendix A replaced with a signatures-only skeleton pointing at
    src/dispatch.rs — drops the ~200-line body sketch that was drifting
    from the implementation; shape-choices list kept inline.

Diff stat

+140 / -209 — net compaction, mostly from Appendix A skeletonization.
Substantive diffs concentrated in §2.5, §2.6, §6.11; the rest is anchor
fixes.

Ready for re-review.

@chaodu-agent
Copy link
Copy Markdown
Collaborator

🟢 PR #598 — docs: add ADR for turn-boundary message batching

Verdict: Approve

What problem does it solve?

Documents the design rationale for turn-boundary message batching — the mechanism that collects messages arriving during an in-flight ACP turn and dispatches them as a single batch at turn completion. This ADR is the design companion to the implementation in PR #686.

How does it solve it?

A comprehensive 1068-line ADR (docs/adr/turn-boundary-batching.md) covering:

  • §1 Context — problem statement (1 msg = 1 turn waste), why broker layer, goals/non-goals
  • §2 Mechanism — per-thread mpsc + consumer loop, three invariants (I1 zero-latency first, I2 at-most-one-in-flight, I3 structural fidelity), architecture diagram, producer/consumer lifecycle, error handling (§2.5 race-safe eviction), other_bot_present freshness (§2.6), last_active deferred concern (§2.7)
  • §3 Packing — per-arrival-event template, timestamp additive field, multi-message concatenation, three-way comparison table, Scenarios A-D with concrete examples
  • §4 Configuration — 3-valued mode (per-message/per-thread/per-lane), sizing guidance, phased rollout, migration path
  • §5 Alternatives — mechanism alternatives (debouncing, semaphore, etc.), packing alternatives (RFC MVP message tags), prior art
  • §6 Consequences — compliance rules, prohibited transformations, observability, batch reaction UX, graceful shutdown, smoke matrices

What was considered?

The ADR itself documents alternatives extensively in §5 (debouncing rejected for latency floor, message-index wrapper rejected for schema complexity, etc.). The three-way comparison table (§3.4) clearly shows why repeating the existing <sender_context> template wins over the RFC MVP approach.

Is this the best approach?

Yes. This is an exemplary ADR:

  • Anchor-pinned to a specific commit (v0.8.2-beta.1) so line references don't drift
  • Cross-referenced with implementation PR feat(dispatch): turn-boundary batching dispatcher v2 #686 for validation
  • Scenarios A-D provide concrete test fixtures that the implementation tests against
  • Compliance rules (§6.4) are explicit and enforceable
  • Deferred concerns (§2.7 last_active) are honestly documented rather than hand-waved

Detailed notes

🟢 INFO — Well done

  • Three invariants (I1, I2, I3) provide a clear framework for evaluating any future change
  • §2.5 race-safe eviction with generation counter is thoroughly reasoned
  • §3.6 Scenarios A-D serve as both documentation and test specification — PR feat(dispatch): turn-boundary batching dispatcher v2 #686 implements tests for all four
  • §4.2 sizing guidance with real sampling data from multi-bot deployments
  • §6.9 and §6.11 smoke matrices define explicit Phase 1 must-do validation
  • Rollback path for Scenario D (§3.6) is concrete: code change, not runtime toggle
  • Honest about what is NOT solved (§2.7 zombie connections, §3.7 inbound field fidelity)

🟡 NIT — Non-blocking suggestions

  1. Status should be "Accepted" — The implementation (PR feat(dispatch): turn-boundary batching dispatcher v2 #686) is complete and ready for merge. Consider updating from "Proposed" to "Accepted" when both PRs land.

  2. Merge order — This ADR should land before or simultaneously with PR feat(dispatch): turn-boundary batching dispatcher v2 #686, since feat(dispatch): turn-boundary batching dispatcher v2 #686 references it in code comments. Consider squash-merging this first.

🔴 SUGGESTED CHANGES — None

No blocking issues found.

brettchien added a commit to brettchien/openab that referenced this pull request May 5, 2026
…ts, fix ADR path

- main.rs: collapse 3x repeated (cap, grouping, idle) match blocks into
  dispatch::dispatch_params(mode, max_buffered).
- dispatch.rs: replace magic 4 / 512 in estimate_tokens with named
  CHARS_PER_TOKEN_ESTIMATE / TOKENS_PER_IMAGE_ESTIMATE constants.
- dispatch.rs: fix top-level ADR reference to point at the actual
  docs/adr/turn-boundary-batching.md path landing in openabdev#598.

Addresses chaodu-agent NITs openabdev#1, openabdev#2, openabdev#5 from PR openabdev#686.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@brettchien
Copy link
Copy Markdown
Contributor Author

@masami-agent re your b9cf3f8 findings:

  • 🔴 PR description filename — fixed, now references docs/adr/turn-boundary-batching.md. (Branch name adr/batched-turn-packing left as-is for git history continuity; description is the canonical pointer.)
  • 🟡 Self-referential PR #598 (superseded) entry in References — removed in 771e87a as part of the fact-check pass (vestige from when this PR was the standalone packing ADR before the v0.1 fold-in). The remaining mention in the v0.1 changelog entry is deliberate historical record; happy to rewrite that line if you'd rather it not name PR docs: add ADR for turn-boundary message batching #598 directly.

@chaodu-agent re your 771e87a NITs:

@chaodu-agent
Copy link
Copy Markdown
Collaborator

LGTM ✅ — Thorough ADR documenting the turn-boundary batching design. Well-synchronized with the implementation in PR #686. Ready to merge (should land before #686).

📋 Review — Four Questions

1. What problem does this solve?

Documents the design decision for turn-boundary message batching — how N messages arriving during an in-flight ACP turn are buffered and dispatched as a single batch. Extracted from RFC #580 into a standalone ADR.

2. How does it solve it?

1068-line ADR covering:

  • §1: Context — problem statement, why broker layer, goals/non-goals
  • §2: Mechanism — three invariants (I1 zero-latency, I2 at-most-one-in-flight, I3 structural fidelity), architecture diagram, producer/consumer lifecycle, SendError eviction (§2.5), other_bot_present freshness (§2.6)
  • §3: Packing — <sender_context> delimiter repeated N times, standalone Text block layout, scenarios (single, multi-author interleaved, image-in-separate-message)
  • §4: Configuration — 3-valued MessageProcessingMode (per-message/per-thread/per-lane), Phase 1 scope
  • §5: Rejected alternatives — pre-turn debouncing, <message index=N> wrapper
  • §6: Operational — batch reactions, graceful shutdown, metrics, idle timeout, SendError staging matrix

3. What alternatives were considered?

ADR §5 documents rejected alternatives:

  • Pre-turn debouncing (imposes latency floor on isolated messages)
  • <message index=N> wrapper schema (adds complexity, breaks I3)
  • Per-channel/global merging (conflates independent conversations)

4. Is this the best approach?

Yes. The ADR is well-structured, fact-checked against the implementation (PR #686), and properly anchored to specific commits.

🟢 INFO — Well done:

  • Anchor pinning to v0.8.2-beta.1 (52052b8) for released-code references — prevents drift confusion
  • Cross-check anchor against PR feat(dispatch): turn-boundary batching dispatcher v2 #686 head (e119abf) for new module descriptions
  • Three invariants (I1, I2, I3) provide clear design constraints that all later decisions reference
  • §3.6 scenarios (A, B, C) with concrete examples of how packing preserves structural truth
  • §6.10 per-mode idle timeout with Little's Law rationale
  • §6.11 SendError staging smoke matrix — documents manual test plan for hard-to-automate paths
  • Latest commit (v0.6) syncs ADR with final PR feat(dispatch): turn-boundary batching dispatcher v2 #686 implementation decisions (3-valued mode, standalone delimiter block, sequential reactions)
  • Frontmatter links to implementation PR feat(dispatch): turn-boundary batching dispatcher v2 #686 — bidirectional traceability

🟢 No issues found. This is a well-maintained living ADR that accurately reflects the implementation.

🔍 Baseline Check (Step 0)

@chaodu-agent chaodu-agent enabled auto-merge May 5, 2026 19:50
@chaodu-agent chaodu-agent disabled auto-merge May 5, 2026 19:50
@thepagent thepagent merged commit 22968c8 into openabdev:main May 5, 2026
4 checks passed
Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

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

docs/adr/turn-boundary-batching.md line 807: Rule 8 contradicts the SendError decision in §2.5. §2.5 says a first SendError should evict, spawn a fresh consumer, and re-send once with no user-visible signal when the retry succeeds; only a failed retry should emit ❌ + ⚠️ + Err. But this compliance rule says whenever submit observes SendError, the failure must surface immediately. Since this section is defined as the future test surface, it would require implementations/tests to reject the intended happy path. Please scope rule 8 to the retry-failed case, and keep the residual-loss note as-is.

thepagent pushed a commit that referenced this pull request May 5, 2026
* feat(dispatch): turn-boundary batching dispatcher v2 per ADR v0.3

* refactor(dispatch): cleanup naming, parallelize queued reactions, use configured emoji on SendError

- Rename ThreadHandle._consumer → consumer (we actually .abort() it on cancel)
- Replace ThreadHandle::drain_pending(&mut self) with pending_count(&self) —
  read-only signature, name no longer implies side effects
- Parallelize 👀 reactions in dispatch_batch via futures::join_all instead of
  serial loop — first-token latency no longer scales with batch size
- SendError ❌ reaction now uses router.reactions_config() instead of
  ReactionsConfig::default() — respects user-configured emoji
- shutdown() switches to iter() (no longer needs &mut after the rename above)
- Tighten doc comments
- Cargo.lock: sync to openab 0.8.2 (Cargo.toml already at 0.8.2)

* feat(discord): add /cancel-all slash command

Adds the standalone /cancel-all path from ADR §4.4 turn-boundary batching.
Unlike /reset, /cancel-all is non-destructive to the session.

- /cancel-all: dispatcher.cancel_buffered() + pool.cancel_session()
  → drops buffered messages + aborts in-flight ACP turn, keeps session
- /reset: unchanged (still drops buffered + cancels in-flight + tears down
  session); doc comment updated to reflect that /reset is a superset of
  /cancel-all rather than "/reset includes /cancel-all"

Discord-only — Slack adapter explicitly drops slash_commands envelopes
(no thread routing on channel-level delivery), Gateway has no user-facing
slash command surface.

Response messages cover all four (cancel_session result × dropped count) cases.

* refactor: unify PerMessage and Batched modes through Dispatcher

Both modes now serialize through the per-thread Dispatcher consumer
task. PerMessage = max_buffered_messages=1 (each message dispatches
alone, FIFO). Batched = configured cap (greedy drain up to
max_batch_tokens).

Removes the bifurcated match in Slack/Discord/Gateway hot paths,
eliminates the Option<Arc<Dispatcher>> indirection, and addresses
chaodu-agent PR #686 review concern about PerMessage FIFO regression
after the KeyedAsyncQueue removal.

* chore(dispatch): address PR #686 NITs

- Extract duplicated days_to_ymd / ISO 8601 conversion from slack.rs
  + gateway.rs into new src/timestamp.rs (with unit tests).
- Add sender_name to BufferedMessage per ADR §2.3 — denormalised from
  sender_json so dispatch_batch tracing doesn't pay a JSON parse.
- impl std::error::Error for DispatchError so it composes with anyhow.

* fix(dispatch): idle eviction, config validation, avoid clone, timestamp precision

- Add 5-min idle timeout to consumer_loop to prevent per-thread handle/task
  leak (unbounded growth from one-shot thread keys like Slack non-thread msgs)
- Validate max_buffered_messages > 0 at config load time (prevents panic from
  tokio::sync::mpsc::channel(0))
- Use into_iter() in dispatch_batch to avoid deep-copying extra_blocks
  (may contain base64 image data)
- Add TODO comment for gateway multibot detection
- Use real milliseconds in now_iso8601() via dur.subsec_millis()

Co-authored-by: 超渡法師 <[email protected]>

* fix(dispatch): proactive stale-entry cleanup + transparent retry on idle exit

- submit() now checks consumer.is_finished() before using an existing
  handle, removing stale entries proactively (fixes map leak for one-shot
  thread keys that never get a second submit)
- On SendError, transparently evict + rebuild + retry once instead of
  surfacing an error to the user (fixes first-message-after-idle being
  treated as ConsumerDead)
- Only report ConsumerDead if the retry also fails (truly unexpected)

* fix(dispatch): periodic sweep of stale per-thread entries

- Add Dispatcher::sweep_stale() that retains only entries whose consumer
  task is still running (map.retain + is_finished check)
- Wire into main.rs cleanup task (60s interval, alongside pool.cleanup_idle)
- Prevents unbounded map growth from one-shot thread keys (e.g. Slack
  non-thread messages) that never receive a second submit()
- dispatchers Vec wrapped in Arc<Mutex<>> so cleanup task can access it

* feat(dispatch): add per-lane batching mode (default for "batched" alias)

Extends MessageProcessingMode from {PerMessage, Batched} to three values:
- PerMessage: each message → one ACP turn (unchanged default behaviour)
- PerThread:  thread-wide buffer, all senders share one batch (old "Batched")
- PerLane:    per (thread, sender) buffer, each sender gets its own ACP turn

The legacy alias "batched" now resolves to PerLane — the recommended default
for batching, since per-lane eliminates the silent-drop risk where a single
mixed-sender ACP turn produces one reply that may forget to address some
senders. Existing configs continue to load without change but now run under
per-lane semantics.

Implementation:
- Adds BatchGrouping enum to dispatch.rs and `Dispatcher::key()` helper that
  builds the per-thread map key from (platform, thread_id, sender_id).
  PerThread mode ignores sender_id; PerLane includes it.
- main.rs translates MessageProcessingMode to (cap, BatchGrouping) when
  constructing each platform's Dispatcher.
- Discord/Slack/Gateway adapters use `dispatcher.key(...)` instead of
  hand-rolled format!() at submit and slash-command sites.
- Session pool keys remain per-thread (unchanged) — the ACP session is
  shared across lanes by design; turns serialise through the shared session.
- /cancel-all and /reset use the invoker's lane key (B1: cancel only own
  lane) but still cancel/reset the shared session (B4-a: keep escape hatch
  from a runaway in-flight turn).

Tests:
- dispatch::tests::key_per_thread_ignores_sender / key_per_lane_includes_sender
- config::tests::message_processing_mode_{parses_per_message,parses_per_thread,
  parses_per_lane,batched_alias_is_per_lane,default_is_per_message,
  unknown_value_errors}
- 224 tests passing.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(dispatch): /reset and /cancel-all clear all lanes in thread

Replaces the per-key Dispatcher::cancel_buffered with cancel_buffered_thread,
which prefix-matches every per-thread handle for a (platform, thread_id) pair
and aborts each consumer. Both PerThread keys (`platform:thread`) and PerLane
keys (`platform:thread:sender`) are dropped, with care taken to avoid the
substring trap (T1 must not match T10).

Behaviour:
- /cancel: unchanged — stop in-flight ACP turn only, queue continues.
- /cancel-all: stop in-flight + drop every lane's buffer in the thread (was:
  invoker's lane only). The nuclear escape hatch — keeps ACP context, clears
  queued work so a human can intervene.
- /reset: drop every lane's buffer + tear down the ACP session (was:
  invoker's lane only). Next message in the thread starts a fresh session.

Gateway:
- run_gateway_adapter now also receives the AdapterRouter, so the upstream
  /reset and /cancel slash-command interception (added on main while this
  branch was in review) compiles after rebase.
- Gateway /reset gets the same all-lanes drop as Discord; /cancel keeps the
  in-flight-only semantics from upstream.
- /cancel-all is intentionally not added to the gateway interception path.

Tests: 227 passing (+3 new dispatcher tests covering PerThread drop,
PerLane all-lanes drop, and the T1-vs-T10 prefix-collision guard).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(config)!: drop "batched" alias, only per-message/per-thread/per-lane accepted

The legacy `"batched"` value (which resolved to PerLane on this branch) is
removed. Configs using `message_processing_mode = "batched"` will now fail
to parse with an `unknown variant "batched"` error pointing at the three
accepted values, forcing an explicit migration to per-thread or per-lane.

The two batching modes have meaningfully different semantics (shared vs
isolated buffer per sender), so a silent default is the wrong call —
users should pick deliberately.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(dispatch): restore shared thread sessions and abort consumers on shutdown

Co-authored-by: Brett Chien <[email protected]>

* feat(chart): expose message_processing_mode and batching params

Adds messageProcessingMode / maxBufferedMessages / maxBatchTokens to the
Discord, Slack, and Gateway sections of the chart. Without these the
turn-boundary batching modes shipped in PR #686 are unreachable from a
helm-deployed instance — the Rust binary just falls back to per-message.

- configmap.yaml: render the three keys for each platform when set, with
  enum validation matching the Rust deserializer
  ("must be one of: per-message, per-thread, per-lane").
- values.yaml: commented examples for each platform.
- tests/message-processing-mode_test.yaml: 12 helm-unittest cases covering
  render, enum rejection, omit-when-unset, and numeric param render across
  all three platforms.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor: rename enum variants to drop redundant Per prefix

Aligns MessageProcessingMode and BatchGrouping with the rest of the
codebase (TableMode, AllowBots, ToolDisplay, TurnSeverity, etc.) where
variants don't repeat the enum-name-derived prefix. Also fixes the CI
clippy::enum_variant_names failure on PR #686.

Wire format unchanged — manual Deserialize still matches per-message /
per-thread / per-lane strings; helm chart and TOML configs need no edits.

- MessageProcessingMode { PerMessage, PerThread, PerLane } -> { Message, Thread, Lane }
- BatchGrouping { PerThread, PerLane } -> { Thread, Lane }

* feat(config): validate max_batch_tokens > 0

Setting max_batch_tokens=0 doesn't crash but forces every batch to size 1
via the consumer loop's token-cap check — functionally per-message mode
through a confusing path. Reject it at config parse time, alongside the
existing max_buffered_messages > 0 check.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* test(dispatch): cover sweep_stale and shutdown

Add an alive_consumer_handle helper (parks on pending::<()>) and four
unit tests:

- sweep_stale removes finished consumers, leaves running ones alone
- shutdown clears the per-thread map and aborts running consumers
  (verified via abort_handle().is_finished() after a runtime tick)

These paths are simple but safety-critical (SIGTERM cleanup + idle-task
GC); the existing dummy_handle / make_dispatcher scaffolding already
covers the test surface, so no new mocks needed.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* test(dispatch): cover consumer_loop via DispatchTarget trait seam

Closes the NIT 2 gap from PR #686 review. The consumer_loop orchestration
(greedy drain / token cap overflow / idle timeout / SendError eviction)
was previously only verified by manual staging smoke. The trait seam
also unblocks the §2.5 SendError end-to-end test.

Refactor:
- DispatchTarget trait (reactions_config / ensure_session /
  stream_prompt_blocks) extracted from AdapterRouter's surface.
  AdapterRouter implements it by delegation.
- Dispatcher now holds Arc<dyn DispatchTarget>. Production callsites
  unchanged — Arc<AdapterRouter> auto-coerces via CoerceUnsized.
- Add Dispatcher::with_idle_timeout (test knob); Dispatcher::new keeps
  the DEFAULT_CONSUMER_IDLE_TIMEOUT (5 min) production default.

Tests:
- MockDispatchTarget records dispatches; MockChatAdapter is a no-op stub.
- consumer_dispatches_single_message_as_one_batch (happy path)
- consumer_greedy_drain_combines_queued_messages_into_one_batch
  (3 pre-loaded msgs → 1 dispatch with 3 ContentBlocks)
- consumer_token_cap_splits_batch_preserving_fifo
  (2x 80-token msgs + cap=100 → 2 FIFO dispatches)
- consumer_exits_after_idle_timeout_with_no_messages (50ms timeout)
- submit_evicts_dead_handle_and_retries_with_fresh_consumer
  (manufactured dead handle: rx dropped, consumer parked → SendError
  → eviction + retry on fresh consumer)

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(adapter): make SenderContext.timestamp truly additive

Wraps the field in Option<String> with skip_serializing_if so consumers
that pre-date the addition see no new key in the serialized JSON.
All four producers (slack, discord, gateway, cron) wrap their existing
values in Some(...). Schema string stays openab.sender.v1.

* docs(dispatch): note re-acquire-after-await safety in submit

Calls out why re-acquiring per_thread after tx.send().await cannot
deadlock — the first lock guard is dropped before the await point.

* fix(adapter): use sender_context as standalone delimiter, split prompt into own block

pack_arrival_event now emits per arrival:
  [Text "<sender_context>{json}</sender_context>"]   (delimiter)
  [Text transcript blocks from extra_blocks]
  [Text prompt]                                      (omitted if empty)
  [non-Text blocks (e.g. Image)]

The sender_context block stands alone as a structural delimiter so agents
can locate arrival boundaries by scanning for `<sender_context>` openers
in batched dispatch. Within each arrival, transcript text precedes the
typed prompt to match pre-batching adapter UX (voice content first), and
images trail the prompt as before. Tests updated to reflect the new
per-arrival block count (2 minimum: delimiter + prompt; +1 per transcript;
+N for image attachments).

* fix(gateway): import AdapterRouter so handle_config_command compiles

handle_config_command's signature uses &AdapterRouter but only
crate::adapter::{ChannelRef, ChatAdapter, MessageRef, SenderContext}
were imported, so cargo check failed with E0425. Add AdapterRouter to
the use list (the other reference at line 482 already uses the fully
qualified path).

* fix(timestamp): parse Slack ts as f64 to preserve decimal semantics

Previously slack_ts_to_iso8601 split on '.' and parsed the fractional
substring as an integer, treating ".12" as 12 ms instead of 120 ms.
Parsing the entire string as f64 carries decimal semantics correctly
without any string-padding logic.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor(discord): drop approximate count from /cancel-all message

The buffered-message count is approximate (sweep races with new
arrivals) so surfacing an exact number to users was misleading. Show
a binary "cleared / nothing" signal instead. The pending_count() API
stays for logs and metrics.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* docs(dispatch): annotate per_thread mutex lock sites with SAFETY comments

Make the no-.await-while-locked invariant explicit at each lock
acquisition site so future edits can't silently introduce an .await
without tripping the comment. The struct-level note at line 183 stays
as the higher-level explanation.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor(dispatch): apply queued reactions sequentially

Replace futures_util::future::join_all with a sequential await loop.
Batches are typically small (low single digits) so the serialization
cost is sub-second and not user-visible, and the dispatch path no
longer pulls in join_all just for one call.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor(dispatch): per-mode consumer idle timeout (10s for per-message)

Per-message mode (cap=1) doesn't benefit from holding consumers across
message gaps — there is no batch window to preserve — so a 5-minute
idle timeout left consumer tasks lingering long after they were useful.
Add PER_MESSAGE_CONSUMER_IDLE_TIMEOUT (10s), wire it through main.rs
based on each adapter's message_processing_mode, and drop the unused
Dispatcher::new wrapper.

By Little's Law (steady-state idle count = arrival rate × idle window),
this cuts per-message-mode idle dispatcher footprint by 30x for the
same arrival rate while keeping batched modes' 5-minute window so
between-trigger lanes aren't torn down on every message.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor(dispatch): extract dispatch_params, name token-estimate consts, fix ADR path

- main.rs: collapse 3x repeated (cap, grouping, idle) match blocks into
  dispatch::dispatch_params(mode, max_buffered).
- dispatch.rs: replace magic 4 / 512 in estimate_tokens with named
  CHARS_PER_TOKEN_ESTIMATE / TOKENS_PER_IMAGE_ESTIMATE constants.
- dispatch.rs: fix top-level ADR reference to point at the actual
  docs/adr/turn-boundary-batching.md path landing in #598.

Addresses chaodu-agent NITs #1, #2, #5 from PR #686.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* docs: clarify schema evolution comment + dispatchers triple-Arc rationale

- adapter.rs: note that future breaking changes should bump to v1.1+
- main.rs: explain why Arc<Mutex<Vec<Arc<Dispatcher>>>> is necessary
  (shared with cleanup task + shutdown; pushes at startup only)

Addresses maintainer NITs from PR #686 review.

Co-Authored-By: 超渡法師 <[email protected]>

* docs: add message dispatch modes guide (per-message vs per-thread vs per-lane)

Decision guide for operators choosing between the three modes, with
config examples and trade-off explanations.

Co-Authored-By: 超渡法師 <[email protected]>

* docs(dispatch): add ASCII diagrams for all three modes + consumer loop

Visual explanation of per-message vs per-thread vs per-lane behavior,
plus the internal consumer_loop batching flow.

Co-Authored-By: 超渡法師 <[email protected]>

* docs: clarify per-message is the default behavior

* docs(dispatch): add explicit pros/cons and comparison table for each mode

---------

Co-authored-by: Brett Chien <[email protected]>
Co-authored-by: 超渡法師 <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
Co-authored-by: shaun-agent <[email protected]>
Co-authored-by: brettchien <[email protected]>
Co-authored-by: chaodu-agent <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants