@@ -8,6 +8,7 @@ import type {
88 ConversationGraphTurn ,
99 ConversationReadItem ,
1010 ConversationTurn ,
11+ ConversationTurnState ,
1112 ConversationWatermark ,
1213 RunId ,
1314 SessionId ,
@@ -27,7 +28,7 @@ import { encodeLiveBranchCursor } from '../session/live-session';
2728import type { SessionRecordRegistry } from '../session/session-record-registry' ;
2829import type { ConversationCheckpointService } from './checkpoint-service' ;
2930import type { ProviderPartition } from './lineage-attribution' ;
30- import { pathToLeaf } from './lineage-attribution' ;
31+ import { pathToLeaf , settledWithProvider } from './lineage-attribution' ;
3132import type { ConversationLiveJournals } from './live-journal' ;
3233import { inflightChunkKey } from './live-journal' ;
3334import type { ConversationTurnService } from './turn-service' ;
@@ -58,6 +59,8 @@ export interface ConversationReadResult {
5859}
5960
6061const INPUT_SUMMARY_MAX_LENGTH = 140 ;
62+ /** Turns a peer can see: running or settled. */
63+ const VISIBLE_TURN_STATES = new Set < ConversationTurnState > ( [ 'running' , ...TERMINAL_TURN_STATES ] ) ;
6164const WHITESPACE_RUN_RE = / \s + / g;
6265/** One page = one logical tunnel message; oversized reassembly is silently dropped by the tunnel
6366 * (the history-util.ts byte-budget rationale applies verbatim). */
@@ -95,6 +98,9 @@ export class ConversationProjectionService {
9598 sessionTurns . sort ( byCreation ) ;
9699 const graphTurns : ConversationGraphTurn [ ] = [ ] ;
97100 for ( let i = 0 , len = sessionTurns . length ; i < len ; i ++ ) {
101+ // A turn that has not run yet is the submitting client's alone: peers see it once it runs
102+ // or fails, which is also when the tree announces it.
103+ if ( ! VISIBLE_TURN_STATES . has ( sessionTurns [ i ] . state ) ) continue ;
98104 const summary = yield * inputSummary ( sessionTurns [ i ] ) ;
99105 graphTurns . push (
100106 summary === undefined ? sessionTurns [ i ] : { ...sessionTurns [ i ] , inputSummary : summary } ,
@@ -157,11 +163,9 @@ export class ConversationProjectionService {
157163 }
158164 offset = decoded . offset ;
159165 }
160- // Positional attribution is sound only on the active lineage: a sibling lineage has the
161- // same path length by construction (and can carry identical prompt text on a retry), so an
162- // inactive-leaf read renders host rows + placeholders until per-turn bindings (CODE-632).
163- const isActiveLineage = leafTurnId !== undefined && leafTurnId === record . activeLeafTurnId ;
164- const durable = yield * composeDurable ( record , path , isActiveLineage ) ;
166+ const activePath =
167+ leafTurnId === record . activeLeafTurnId ? path : pathToLeaf ( byId , record . activeLeafTurnId ) ;
168+ const durable = yield * composeDurable ( record , path , activePath ) ;
165169 const { tail, watermark } = composeTail ( request . sessionId , record . eventEpoch , path ) ;
166170 const { events, nextOffset } = pageReadItems (
167171 durable ,
@@ -194,36 +198,59 @@ export class ConversationProjectionService {
194198 private composeDurable (
195199 record : SessionRecord ,
196200 path : ConversationTurn [ ] ,
197- isActiveLineage : boolean ,
201+ activePath : ConversationTurn [ ] ,
198202 ) : Effect . Effect < ConversationReadItem [ ] , OperationError > {
199203 const { checkpoints, records, turns } = this ;
204+ const hostContents = (
205+ lineage : ConversationTurn [ ] ,
206+ ) : Effect . Effect < ( ContentBlock [ ] | undefined ) [ ] , OperationError > =>
207+ Effect . forEach ( lineage , ( turn ) => turns . hostUserContent ( turn ) ) ;
200208 return Effect . gen ( function * ( ) {
201209 const items : ConversationReadItem [ ] = [ ] ;
202- const contents : ( ContentBlock [ ] | undefined ) [ ] = [ ] ;
203- for ( let i = 0 , len = path . length ; i < len ; i ++ ) {
204- contents . push ( yield * turns . hostUserContent ( path [ i ] ) ) ;
205- }
210+ const contents = yield * hostContents ( path ) ;
206211 let attributed : ProviderPartition [ ] = [ ] ;
207212 let leading : AgentHistoryEvent [ ] = [ ] ;
208- // The active lineage reads the live history. An inactive lineage reads only a history of
209- // its own (its leaf run's, when that is not the live one): a sibling that shares the live
210- // history has the same path length by construction, so slicing it positionally would hand
211- // it the active lineage's rows. Reading the corpus also backfills replay bindings.
213+ const settled = settledWithProvider ( path ) ;
214+ const anchor = settled . at ( - 1 ) ;
215+ const activeIds = new Set ( activePath . map ( ( turn ) => turn . turnId ) ) ;
212216 const liveHistoryId = records . historyId ( record . sessionId ) ;
213- const lineageHistoryId = isActiveLineage
214- ? liveHistoryId
215- : inactiveLineageHistoryId ( record , path , liveHistoryId ) ;
216- if ( lineageHistoryId !== undefined ) {
217+ // Reading a corpus also backfills replay bindings on it.
218+ const ownHistoryId =
219+ anchor === undefined || activeIds . has ( anchor . turnId )
220+ ? undefined
221+ : runHistoryId ( record , anchor . runId ) ;
222+ if ( ownHistoryId !== undefined && ownHistoryId !== liveHistoryId ) {
223+ // An inactive lineage on a history of its own reads it whole: rows, cursors, and bindings
224+ // all live there — never the live copy a later fork made of its prefix.
217225 const attribution = yield * checkpoints . attributeLineage (
218226 record ,
219227 path ,
220228 contents ,
221- lineageHistoryId ,
229+ ownHistoryId ,
222230 ) ;
223231 if ( attribution !== undefined ) {
224232 attributed = attribution . attributed ;
225233 leading = attribution . leading ;
226234 }
235+ } else if ( anchor !== undefined ) {
236+ // The turns a lineage shares with the active one read where the active lineage reads: the
237+ // live history, verified from the start, cut to that shared prefix. Whatever lies beyond
238+ // stays a placeholder — a sibling sharing the live history has the same path length by
239+ // construction (and can repeat the prompt text on a retry), so slicing it positionally
240+ // would hand it the active lineage's rows.
241+ const shared = settled . filter ( ( turn ) => activeIds . has ( turn . turnId ) ) . length ;
242+ if ( shared > 0 ) {
243+ const attribution = yield * checkpoints . attributeLineage (
244+ record ,
245+ activePath ,
246+ activePath === path ? contents : yield * hostContents ( activePath ) ,
247+ liveHistoryId ,
248+ ) ;
249+ if ( attribution !== undefined ) {
250+ attributed = attribution . attributed . slice ( 0 , shared ) ;
251+ leading = attribution . leading ;
252+ }
253+ }
227254 }
228255 for ( let i = 0 , len = leading . length ; i < len ; i ++ ) {
229256 items . push ( projectedItem ( undefined , leading [ i ] ) ) ;
@@ -467,17 +494,6 @@ function runHistoryId(record: SessionRecord, runId: RunId): AgentHistoryId | und
467494 return record . runs . find ( ( run ) => run . runId === runId ) ?. historyId ;
468495}
469496
470- function inactiveLineageHistoryId (
471- record : SessionRecord ,
472- path : readonly ConversationTurn [ ] ,
473- liveHistoryId : AgentHistoryId | undefined ,
474- ) : AgentHistoryId | undefined {
475- const leaf = path . at ( - 1 ) ;
476- if ( leaf === undefined ) return ;
477- const historyId = runHistoryId ( record , leaf . runId ) ;
478- return historyId === liveHistoryId ? undefined : historyId ;
479- }
480-
481497function projectedUserRow (
482498 turn : ConversationTurn ,
483499 content : ContentBlock [ ] ,
0 commit comments