fix(web): parallel turns — announce overlap, assign spans by conversation id - #632
Merged
Merged
Conversation
added 2 commits
August 25, 2026 16:45
The transcript marked concurrency only WITHIN a turn: overlapping
agent-level lanes got a marker row and reciprocal jump chips. But turn
partitioning gives each Maple sub-agent or dispatched run its own turn
— its own conversation id, or its own trace once a queue hop drops the
span link — so a fan-out arrives as sibling chapters and read as
"TURN 3, then TURN 4": the one thing the timestamps deny.
Lifts the lane clustering into `clusterByOverlap`, generic over
{ startMs, endMs }, and uses it for both levels. Same honesty rules
either way: a running max-end so two short runs nested in one long one
stay one cluster, an overlap window reported only where every member
genuinely shared one, and `parallelWith` listing only the pairs that
actually met — a chain (A–B, B–C) never links A to C.
Turns with no AI activity sit it out: an `empty-turn` stub is HTTP/DB
work, and pairing one with a real turn would claim a concurrency the
reader cannot see. Markers and chips are structural chrome, so a query
drops them exactly as it drops the lane markers.
The lab fixture grows a dispatched fan-out — `release-triage` queues
two checks and waits — which lands as three overlapping chapters.
Deliberately keyed on agent roots rather than conversation ids:
`findAnchors` prefers conversation ids for the WHOLE session as soon as
two exist, so stamping them would repartition the other seventeen turns.
Maple's investigation fan-out stamps a `maple_ai.turn.id` per hypothesis lane and launches the lanes with `Promise.all`, so the conversation ids that partition the session INTERLEAVE. Span assignment was a pure time cursor — a turn owned everything that started before the next anchor — and concurrent anchors start milliseconds apart, so each lane kept only a sliver and the whole concurrent phase, every lane's final chat and its `execute_tool submit_candidate`, landed in whichever lane anchored last. The transcript re-threads by parentage only within a turn, so it could not repair it: the tail spans' parent lived in another turn's bucket, they became forest roots and rendered at depth 0, unattributed, under a chapter that never ran them. On the conversation rule the id a span CARRIES now decides, keeping the existing exclusion of ids equal to the session id. Untagged spans — the app's own HTTP and database work sharing the agent's trace — follow parentage to their nearest tagged ancestor, which is cheap and beats the cursor exactly where the cursor was wrong; `parentSpanId` is intra-trace, so a span with no tagged ancestor still falls back to the time cursor. The agent-root and trace rules are untouched, and turn start/end/traces/ failed/label keep deriving from the member sets. This also fixes an adjacent case: two conversation anchors sharing a millisecond used to leave the earlier bucket empty and drop its turn. An anchor carries its own id, so it keeps its turn. With the lanes now owning their spans they are sibling turns, which is what the chapter-level parallel markers were built for — a transcript test pins the combination end to end.
JeremyFunk
had a problem deploying
to
pr-preview
August 25, 2026 14:50 — with
GitHub Actions
Failure
🍁 Maple PR previewWarning Preview cleanup could not be confirmed. The Alchemy teardown outcome was Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related fixes for concurrent agent work that lands as sibling turns rather than lanes inside one turn — the shape Maple's own investigation fan-out produces, since every pass stamps its own turn id.
Announce turns that ran in parallel
The transcript's parallelism treatment stopped at lane level: overlapping lanes inside one turn got a fork marker and jump chips, but overlapping turn chapters rendered as silently sequential. Turn-level overlap now gets the same treatment — the lane clustering (running max-end, honest overlap window only when a shared window exists) is lifted into a generic
clusterByOverlapused by both levels. Aparallel-turnsmarker row precedes the cluster ("3 turns ran at the same time — they overlap 12:31:51 → 12:32:02. Each turn is shown whole, in order: …") and each member's header carries reciprocal "ran in parallel with TURN n" jump chips, collapsed or not. Markers stay structural chrome: dropped under an active filter, excluded for no-AI chapter stubs.Assign spans to conversation turns by the id they carry
buildSessionTurns' conversation-id partition assigned spans by a pure time cursor, so N concurrent turn ids interleaved: every lane's tail spans — its final answer, its submit tool call — landed in whichever lane started last, and the transcript rendered them unattributed in the wrong chapter (its parentage re-threading is per-turn and could not repair a cross-turn split).The conversation partition now assigns each span to the turn whose id it carries; untagged spans (the app's own HTTP/DB children) follow
parentSpanIdto the nearest tagged ancestor, and only spans with no link to any turn fall back to the time cursor. The session-id-derived exclusion, the agent-root and trace partitions, and pre-first-anchor behavior are all unchanged, and turn bounds/traces/failure/label still derive from the member sets. Same-millisecond anchors no longer drop the earlier turn on this path — an anchor carries its own id.The two fixes compose: once each lane owns its spans, the fan-out reads as correctly-attributed sibling chapters, and the marker is what says they ran together.
Tests: +20 (fan-out regression proving each lane keeps its tail spans — fails on the old assignment; transitive/staggered/nested turn clustering with honest windows; the end-to-end fan-out → sibling chapters → marker combination; within-turn lane clustering anti-regression). 270 scoped tests and web typecheck green.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.