@@ -221,6 +221,14 @@ export type ChatOrchestrator = {
221221 * concern and is filtered out here. A missing `correlationId` means the
222222 * register co-write never ran (or hasn't landed yet) — nothing to look an
223223 * approval up by, so this is treated the same as "not an approval gate".
224+ *
225+ * Upstream ask (CL-7104 remainder, filed — do NOT implement cross-repo
226+ * here): resolving the approval by the dispatched question card's own
227+ * `Message-ID` — the answer dispatched as a reply naming it in
228+ * `In-Reply-To`, per `docs/CHAT.md` — needs the reactor's `gate.blocked`
229+ * event and the hub IPC register co-write to carry that id. That change
230+ * lives in `vendor/intx` and the hub, outside this package; until it
231+ * lands, this `correlationId` lookup stays.
224232 */
225233function gateBlockedCorrelationId ( event : unknown ) : string | undefined {
226234 if (
@@ -693,6 +701,39 @@ function correlateOriginatingWorkbench(
693701 return undefined ;
694702}
695703
704+ /**
705+ * Header-first attribution (CL-7104): the dispatch mail's `Message-ID` IS
706+ * the source row's (`dispatchTurn` stamps `mailMessageIdFor` of the message
707+ * it answers), so the open bracket's mail names the row the reply answers
708+ * — and that row's own `workbenchId` is the originating room. Preferred
709+ * over the event hints and the single-running-turn fallback in `postReply`:
710+ * two turns pending against one agent are told apart because their
711+ * dispatches carry different `Message-ID`s, not because one is newer.
712+ *
713+ * Only ever narrows to a membership: a correlation naming another
714+ * workbench (or none at all — a pre-rollout mail) returns undefined and
715+ * falls through to the existing fallbacks rather than posting somewhere
716+ * the agent was never invited.
717+ */
718+ async function correlateWorkbenchByMail (
719+ turnMailCorrelation : TurnMailCorrelationStore | undefined ,
720+ tenantId : string ,
721+ members : readonly ReplyMember [ ] ,
722+ mailId : string | undefined ,
723+ ) : Promise < string | undefined > {
724+ if ( turnMailCorrelation === undefined || mailId === undefined ) {
725+ return undefined ;
726+ }
727+ const source = await turnMailCorrelation . findTurnMailSource ( {
728+ tenantId,
729+ mailId,
730+ } ) ;
731+ if ( source === undefined ) return undefined ;
732+ return members . some ( ( member ) => member . workbenchId === source . workbenchId )
733+ ? source . workbenchId
734+ : undefined ;
735+ }
736+
696737async function latestTurnForAgent (
697738 agentTurns : Pick < AgentTurnStore , "listTurns" > | undefined ,
698739 tenantId : string ,
@@ -766,7 +807,22 @@ async function postReply(
766807 const runningIds = members
767808 . filter ( ( member ) => member . turnId !== undefined )
768809 . map ( ( member ) => member . workbenchId ) ;
769- const correlated = correlateOriginatingWorkbench ( event , members ) ;
810+ // Attribution order is header, hints, then the running-turn fallbacks
811+ // (CL-7104): the bracket mail's correlation names the row the reply
812+ // answers, so it wins over every heuristic below it. The
813+ // single-running-turn leg after it is deliberately retained until agent
814+ // replies arrive as mail — the sidecar `agent.event` stream carries no
815+ // threading headers today, so a header-less reply with exactly one
816+ // running turn has nowhere else to go. Retiring this bridge is CL-6534
817+ // slice 2's job, NOT this ticket: never extend the fallback to the
818+ // ambiguous (several-running) case, which stays a reported drop.
819+ const byMail = await correlateWorkbenchByMail (
820+ deps . turnMailCorrelation ,
821+ resolved . tenantId ,
822+ members ,
823+ mailId ,
824+ ) ;
825+ const correlated = byMail ?? correlateOriginatingWorkbench ( event , members ) ;
770826 const resolvedTargets =
771827 correlated !== undefined
772828 ? [ correlated ]
0 commit comments