Skip to content

perf(server): ACP subagent replies no longer rewrite the database on every chunk - #29422

Open
M0xoo wants to merge 4 commits into
pingdotgg:mainfrom
M0xoo:perf/acp-subagent-chunk-writes
Open

M0xoo wants to merge 4 commits into
pingdotgg:mainfrom
M0xoo:perf/acp-subagent-chunk-writes

Conversation

@M0xoo

@M0xoo M0xoo commented Nov 2, 2026 •

Copy link
Copy Markdown

Problem

When an ACP agent streams a subagent's reply (Grok native subagents, as measured below; Devin's subagent_context chunks), emitSubagentAssistant emits a message.updated and a turn_item.updated carrying the whole reply so far for every agent_message_chunk. makeSubagentConversationArtifacts marks both streaming: false, so they skip the server's paragraph throttle (assistantStreaming.ts) and live coalescing (ThreadLiveEventCoalescer.ts). Every chunk becomes two stored events, projection rewrites and a WebSocket push. Bytes written grow quadratically with reply length. messageSearchIndex.ts already noted this ("ACP subagents rewrite their finished answer on every streamed chunk").

Change

packages/provider-acp/src/server/adapter.ts, following the pattern of publishTextSegment for the root stream in the same file:

  • A subagent message's first chunk is still emitted at once.
  • Later chunks mark the message pending, and one delayed flush emits the latest text after ACP_SUBAGENT_TEXT_FLUSH_INTERVAL (400 ms). That is the cadence at which root replies are already persisted under the default paragraph streaming mode.
  • Any subagent status update, including completion, flushes pending text first, so the finished answer always lands before the terminal node.
  • replace updates (agent_message) emit immediately and cancel the pending flush.
  • Flushes run under the existing textEmissionPermit, so a late flush cannot overwrite newer text.
  • The parent's delegation result reads subagent.assistantText from memory and is unaffected.

The messageSearchIndex.ts comment is updated to match.

Scope and approval

A focused performance fix inside one adapter function with no new behavior or settings. The quadratic write was already acknowledged in messageSearchIndex.ts, and the fix reuses the coalescing pattern this adapter already applies to the root text stream.

Verification

  • Extended the existing Devin subagent case in AcpAdapterV2.test.ts so the child answer arrives in three chunks. On main it fails with ['O', 'ON', 'ONE']; with the fix the emitted texts are ['O', 'ONE']. The intermediate snapshot is coalesced and completion flushes the final text.
  • 878 tests in the 20 ACP-related server test files pass (src/orchestration-v2/Adapters/, ProviderSwitch.integration.test.ts, src/provider/acp/). vp run check:changed passes.
  • Measurement with a throwaway (uncommitted) harness: a 4,000-chunk, 20 KB subagent reply streamed through the real adapter and the server's paragraph filter, then written to a SQLite file (one insert plus one upsert per event, a simplified layout, so treat the timings as rough):
main fix, 60 chunks/s fix, 150 chunks/s
Events persisted 8,000 336 136
JSON written 87.3 MB 3.7 MB 1.5 MB
SQLite write time ~1,100 ms 33 ms 16 ms
Database file 94.2 MB 4.0 MB 1.7 MB

The final persisted text is 20,000 characters in every run.

  • Real agent: Grok (grok-4.7-build-fast), native background subagent. I ran the same prompt against an isolated dev server, twice with this branch and twice with main's provider-acp adapter. The prompt had the built-in task tool spawn one background general-purpose subagent that writes ~600 words. A temporary trace counted the chunks on the ACP connection; the stored events come from orchestration_events in the dev database:

    Run Subagent chunks received Stored assistant events (message.updated + turn-item.updated) Stored bytes Final answer
    main oxc stack #1 946 946 + 946 = 1,892 5.92 MB 4,570 chars, completed
    main Add open-in-editor feature with Cmd+O shortcut #2 1,017 1,017 + 1,017 = 2,034 6.80 MB 4,966 chars, completed
    this PR oxc stack #1 869 35 + 35 = 70 0.22 MB 4,133 chars, completed
    this PR Add open-in-editor feature with Cmd+O shortcut #2 1,039 18 + 18 = 36 0.12 MB 4,929 chars, completed

    On main, every chunk is two stored events, each carrying the whole answer so far. With this change, stored events drop 27–56x and stored bytes 27–58x. In run Add open-in-editor feature with Cmd+O shortcut #2 with this change, the stored final answer (4,929 chars) equals the total length of the chunks received over the wire.

  • A foreground Grok subagent and Claude via claude-acp send the subagent's answer as one update, so this change does not affect them. Copilot (403 for this account) and Antigravity (the separate ACP runtime is not installed here) were not checked.

  • While a subagent streams, its persisted text can now lag by up to 400 ms.

🤖 Generated with Claude Code (Claude Opus 5.5 in wisp)

…every chunk

Each ACP subagent chunk emitted a message.updated and a turn_item.updated
with the full reply so far. Both are stored as finished (streaming: false),
so they skipped the paragraph throttle and every chunk was persisted.

A message's first chunk is still emitted at once. Later chunks coalesce into
one snapshot per 400 ms, and any subagent status update flushes pending text
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Nov 2, 2026
@macroscopeapp

macroscopeapp Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at cfb12d9

Macroscope's review found this PR approvable — ACP subagent replies now batch intermediate snapshots at a 400 ms cadence instead of rewriting storage on every chunk. Initial output and replacements remain immediate, and pending text is flushed before status updates, keeping the performance change narrowly scoped.

You can add or adjust custom eligibility rules. Learn more.

@M0xoo

M0xoo commented Nov 2, 2026

Copy link
Copy Markdown
Author

Real provider check: Grok (grok-4.7-build-fast)

I ran the same prompt twice against an isolated dev server (vp run dev, worktree .t3): once with this branch and once with main's packages/provider-acp/src/server/adapter.ts. The prompt asked Grok to delegate a ~500-word answer to a subagent and reply only "done". Both turns completed, and Grok spawned a real native subagent each time. Counts come from orchestration_events in the dev database:

this branch main
Turn / subagent status completed / completed completed / completed
Child-thread assistant message.updated 1 1
Child-thread assistant turn-item.updated 1 1
Final subagent reply 3,825 chars, completed 3,987 chars, completed

Result: no regression. The subagent's full answer is stored and completed, and the parent receives the subagent completion and replies.

But this run doesn't show the improvement. Grok delivered the whole subagent answer in a single update, so on main too it was written only once, and the per-chunk path this PR changes never ran. Some other ACP agent must stream subagent chunks for the gain to apply. The Devin replay fixture in AcpAdapterV2.test.ts does, which is the case the extended test and the measurement table cover. The PR description lists Grok among the affected agents. That's inaccurate for this Grok model and CLI version.

🤖 Generated with Claude Code (Claude Opus 5.5 in wisp)

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 3fbde75 and 5795776.

Files selected for processing: 1
  • packages/provider-acp/src/server/adapter.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Walkthrough

ACP subagent assistant text is tracked per native message. Initial and replacement text is emitted immediately, while appended text is coalesced on a 400 ms cadence. Pending text is flushed before result handling, child-state resets, and terminal turn events. Regression tests cover chunked output, failed turns, and idle child sessions.

Changes: ACP subagent text emission

Layer / File(s) Summary
Snapshot emission and pending state
packages/provider-acp/src/server/adapter.ts
The adapter tracks pending assistant messages. It emits initial and replacement text immediately and coalesces appended text on a 400 ms cadence. Writes use the specific message’s text and the shared text-emission permit.
Flush lifecycle and replay assertions
packages/provider-acp/src/server/adapter.ts, apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts, apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts, apps/server/src/orchestration-v2/messageSearchIndex.ts
The adapter flushes pending messages before result handling, terminal status projection, child-state resets, and turn finalization. Replay tests check chunked output, failed-turn text, and coalesced text after child and root sessions become idle. The comment describes rewriting a finished answer while streaming.

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Suggested reviewers: maria-rcks, t3dotgg

Merge Risk: Minimal · up to 57957

The identified text-flush ordering gaps have been addressed, and no remaining issue is established that should block merging after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title is concise, specific, and accurately describes the main performance change: coalescing ACP subagent replies to prevent database rewrites on every chunk.
Description check Passed The description includes all required sections: Problem, Change, Scope and approval, and Verification. It explains the affected behavior, implementation, testing, measured results, limitations, and un…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description includes all required sections: Problem, Change, Scope and approval, and Verification. It explains the affected behavior, implementation, testing, measured results, limitations, and unverified runtimes.

Finishing Touches
Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@coderabbitai coderabbitai Bot left a comment •

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.

  • Fix comments: Fix CodeRabbit comments on this PR

@M0xoo

M0xoo commented Nov 2, 2026

Copy link
Copy Markdown
Author

Correction: real Grok runs do show the improvement

My earlier comment was wrong to conclude Grok is unaffected. That run used a foreground subagent, which Grok returns as one update. When Grok's built-in task tool runs the subagent in the background, the child session streams its reply chunk by chunk over the parent's ACP connection, which is the path this PR changes.

Same prompt, grok-4.7-build-fast, isolated dev server, two runs per side. A temporary trace counted the chunks on the wire; the stored events come from orchestration_events:

Run Subagent chunks received Stored assistant events Stored bytes Final answer
main #1 946 1,892 5.92 MB 4,570 chars, completed
main #2 1,017 2,034 6.80 MB 4,966 chars, completed
this PR #1 869 70 0.22 MB 4,133 chars, completed
this PR #2 1,039 36 0.12 MB 4,929 chars, completed

That's 27–56x fewer stored events and 27–58x fewer stored bytes. In this PR's run #2, the stored final answer equals the total length of the chunks received (4,929 chars), so no text is lost. The PR description's Verification section now has these numbers.

🤖 Generated with Claude Code (Claude Opus 5.5 in wisp)

M0xoo and others added 2 commits November 2, 2026 17:29
A failed turn's terminal can stop event ingestion before the delayed
subagent flush runs, and carryover terminalization projected status
without flushing. Both now emit pending subagent text first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t resets it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment •

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.

  • Fix comments: Fix CodeRabbit comments on this PR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant