Skip to content

Commit 18c254d

Browse files
committed
fix(engine,client-core): read a failed leaf's shared prefix, announce settles, and hide unrun turns
1 parent e6c621f commit 18c254d

7 files changed

Lines changed: 132 additions & 67 deletions

File tree

‎packages/client/core/src/client/conversation-graph-changes.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { SessionId, TurnId } from '@linkcode/schema';
22
import type { Unsubscribe } from '@linkcode/transport';
33

4-
/** One `conversation.graph.changed` broadcast: the graph moved its default leaf or gained shape. */
4+
/** One `conversation.graph.changed` broadcast: the graph moved its default leaf or gained shape
5+
* (a new revision), or a turn reached its terminal state (the revision stands). */
56
export interface ConversationGraphChange {
67
graphRevision: number;
78
activeLeafTurnId?: TurnId;
@@ -12,15 +13,16 @@ type ChangeCb = (change: ConversationGraphChange) => void;
1213
/**
1314
* Per-session register of the newest graph revision the daemon announced on this connection. Not
1415
* a buffer: a store holding a read at an older revision only needs to know that a newer one exists
15-
* and where its leaf is.
16+
* and where its leaf is. Subscribers hear every announcement — a same-revision one carries a turn
17+
* state they must refetch.
1618
*/
1719
export class ConversationGraphChanges {
1820
private readonly latest = new Map<SessionId, ConversationGraphChange>();
1921
private readonly subscribers = new Map<SessionId, Set<ChangeCb>>();
2022

2123
note(sessionId: SessionId, change: ConversationGraphChange): void {
2224
const current = this.latest.get(sessionId);
23-
if (current !== undefined && current.graphRevision >= change.graphRevision) return;
25+
if (current !== undefined && current.graphRevision > change.graphRevision) return;
2426
this.latest.set(sessionId, change);
2527
const subs = this.subscribers.get(sessionId);
2628
if (subs) for (const cb of subs) cb(change);

‎packages/foundation/schema/src/wire/conversation.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ export const conversationWireVariants = [
9999
activeLeafTurnId: TurnIdSchema.optional(),
100100
turns: z.array(ConversationGraphTurnSchema),
101101
}),
102-
/** Session-scoped broadcast: the graph changed shape or moved its default leaf; clients holding
103-
* a stale snapshot revalidate via `conversation.graph.get`. */
102+
/** Session-scoped broadcast: the graph gained a node or moved its default leaf (a new
103+
* `graphRevision`), or a visible turn reached its terminal state (the revision stands); clients
104+
* holding a stale snapshot revalidate via `conversation.graph.get`. */
104105
z.object({
105106
kind: z.literal('conversation.graph.changed'),
106107
sessionId: SessionIdSchema,

‎packages/host/engine/src/__tests__/conversation-projection.test.ts‎

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -411,12 +411,13 @@ describe('conversation projection attribution gate', () => {
411411
await store.saveTurn(shellTurn('turn-b2', 'turn-a', 'b', 'completed', 2));
412412

413413
// The inactive sibling B1 carries IDENTICAL prompt text to the active B2: counts and
414-
// fingerprints both pass, so only the active-lineage gate stops the mis-slice.
414+
// fingerprints both pass, so only the active-lineage gate stops the mis-slice. The shared
415+
// prefix reads where the active lineage reads.
415416
const inactive = await Effect.runPromise(
416417
service.read({ sessionId, leafTurnId: 'turn-b1' as TurnId }),
417418
);
418-
expect(answers(inactive.events)).toEqual([]);
419-
expect(placeholderTurnIds(inactive.events)).toEqual(['turn-a', 'turn-b1']);
419+
expect(answers(inactive.events)).toEqual([['ans-a', 'turn-a']]);
420+
expect(placeholderTurnIds(inactive.events)).toEqual(['turn-b1']);
420421

421422
// The active lineage attributes normally.
422423
const active = await Effect.runPromise(service.read({ sessionId }));
@@ -461,15 +462,22 @@ describe('conversation projection attribution gate', () => {
461462
...shellTurn('turn-b2', 'turn-a', 'b2', 'completed', 2),
462463
runId: forkRunId,
463464
});
465+
// A continue from B1 that never ran: its own lineage still reads from B1's history.
466+
await store.saveTurn({
467+
...shellTurn('turn-c1', 'turn-b1', 'c1', 'failed'),
468+
runId: 'run-3' as RunId,
469+
});
464470

465-
const inactive = await Effect.runPromise(
466-
service.read({ sessionId, leafTurnId: 'turn-b1' as TurnId }),
467-
);
468-
expect(answers(inactive.events)).toEqual([
469-
['ans-a', 'turn-a'],
470-
['ans-b1', 'turn-b1'],
471-
]);
472-
expect(placeholderTurnIds(inactive.events)).toEqual([]);
471+
const expectOwnHistory = async (leafTurnId: TurnId) => {
472+
const inactive = await Effect.runPromise(service.read({ sessionId, leafTurnId }));
473+
expect(answers(inactive.events)).toEqual([
474+
['ans-a', 'turn-a'],
475+
['ans-b1', 'turn-b1'],
476+
]);
477+
expect(placeholderTurnIds(inactive.events)).toEqual([]);
478+
};
479+
await expectOwnHistory('turn-b1' as TurnId);
480+
await expectOwnHistory('turn-c1' as TurnId);
473481

474482
const active = await Effect.runPromise(service.read({ sessionId }));
475483
expect(answers(active.events)).toEqual([
@@ -478,6 +486,34 @@ describe('conversation projection attribution gate', () => {
478486
]);
479487
});
480488

489+
it('reads the shared prefix of a lineage whose leaf failed from the live history', async () => {
490+
const { service, store } = await makeService({
491+
journals: new ConversationLiveJournals(),
492+
record: makeRecord('turn-l' as TurnId, true),
493+
historyEvents: [
494+
providerUser('u-a', 'a'),
495+
providerAnswer('ans-a', 'answer a'),
496+
providerUser('u-l', 'l'),
497+
providerAnswer('ans-l', 'answer l'),
498+
],
499+
});
500+
await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed'));
501+
await store.saveTurn(shellTurn('turn-l', 'turn-a', 'l', 'completed', 1));
502+
// An edit of L refused at dispatch: a failed sibling on the live run, nothing durable ran.
503+
await store.saveTurn(shellTurn('turn-l2', 'turn-a', 'l2', 'failed', 2));
504+
505+
const failed = await Effect.runPromise(
506+
service.read({ sessionId, leafTurnId: 'turn-l2' as TurnId }),
507+
);
508+
expect(answers(failed.events)).toEqual([['ans-a', 'turn-a']]);
509+
expect(placeholderTurnIds(failed.events)).toEqual([]);
510+
expect(
511+
failed.events.flatMap((item) =>
512+
'event' in item && item.event.type === 'user-message' ? [item.turnId] : [],
513+
),
514+
).toEqual(['turn-a', 'turn-l2']);
515+
});
516+
481517
it('attributes nothing when the trailing extra partition is not the in-flight prompt', async () => {
482518
const { service, store } = await makeService({
483519
journals: new ConversationLiveJournals(),

‎packages/host/engine/src/__tests__/engine-turn-submit.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -920,12 +920,15 @@ describe('turn.submit saga', () => {
920920
await vi.waitFor(() => submittedTurnId(h.sent, 's1'));
921921
const firstTurnId = submittedTurnId(h.sent, 's1');
922922
await settleEngineTasks();
923-
// One commit, and the turn's own stop (held until then) settled THIS turn with its checkpoint.
923+
// One commit, and the turn's own stop (held until then) settled THIS turn with its checkpoint;
924+
// the settle re-announces the tree at the same revision.
924925
expect((await h.conversationStore.listTurns(h.sessionId))[0].state).toBe('completed');
925926
expect(await h.conversationStore.listBindings(firstTurnId)).toEqual([
926927
expect.objectContaining({ checkpoint: 'after-first', capturedFrom: 'live' }),
927928
]);
928-
expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toHaveLength(1);
929+
expect(
930+
h.sent.flatMap((p) => (p.kind === 'conversation.graph.changed' ? [p.graphRevision] : [])),
931+
).toEqual([1, 1]);
929932

930933
await submitPrompt(h, 's2', 'second');
931934
await vi.waitFor(() => expect(adapter.sentInputs).toHaveLength(2));

‎packages/host/engine/src/conversation/checkpoint-service.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import type { HistoryBranchCut, HistoryService } from '../session/history-servic
1515
import { promptContentFingerprint } from '../session/live-session';
1616
import type { SessionRecordRegistry } from '../session/session-record-registry';
1717
import type { CorpusAttribution } from './lineage-attribution';
18-
import { attributeCorpus, hasHiddenPrefix, pathToLeaf } from './lineage-attribution';
18+
import {
19+
attributeCorpus,
20+
hasHiddenPrefix,
21+
pathToLeaf,
22+
settledWithProvider,
23+
} from './lineage-attribution';
1924
import type { ConversationTurnService } from './turn-service';
2025
import { TERMINAL_TURN_STATES } from './turn-service';
2126

@@ -269,8 +274,3 @@ function toCut(binding: ProviderTurnBinding): AgentHistoryBranchOptions {
269274
function activePath(record: SessionRecord, turns: ConversationTurn[]): ConversationTurn[] {
270275
return pathToLeaf(new Map(turns.map((turn) => [turn.turnId, turn])), record.activeLeafTurnId);
271276
}
272-
273-
/** The path turns that expect provider rows: settled, and not failed (nothing durable ran). */
274-
function settledWithProvider(path: readonly ConversationTurn[]): ConversationTurn[] {
275-
return path.filter((turn) => TERMINAL_TURN_STATES.has(turn.state) && turn.state !== 'failed');
276-
}

‎packages/host/engine/src/conversation/projection-service.ts‎

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
2728
import type { SessionRecordRegistry } from '../session/session-record-registry';
2829
import type { ConversationCheckpointService } from './checkpoint-service';
2930
import type { ProviderPartition } from './lineage-attribution';
30-
import { pathToLeaf } from './lineage-attribution';
31+
import { pathToLeaf, settledWithProvider } from './lineage-attribution';
3132
import type { ConversationLiveJournals } from './live-journal';
3233
import { inflightChunkKey } from './live-journal';
3334
import type { ConversationTurnService } from './turn-service';
@@ -58,6 +59,8 @@ export interface ConversationReadResult {
5859
}
5960

6061
const 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]);
6164
const 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-
481497
function projectedUserRow(
482498
turn: ConversationTurn,
483499
content: ContentBlock[],

‎packages/host/engine/src/conversation/turn-service.ts‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -343,22 +343,28 @@ export class ConversationTurnService {
343343
}
344344
// A failed turn keeps its ordinal and renders with a state badge, so every device must learn
345345
// the tree gained it — the default leaf did not move.
346-
const graphRevision = this.records.commitGraphShape(sessionId);
347-
const activeLeafTurnId = this.records.get(sessionId)?.activeLeafTurnId;
348-
if (graphRevision !== undefined) {
349-
this.transport.send(
350-
createWireMessage({
351-
kind: 'conversation.graph.changed',
352-
sessionId,
353-
graphRevision,
354-
...(activeLeafTurnId !== undefined && { activeLeafTurnId }),
355-
}),
356-
);
357-
}
346+
this.announceGraph(sessionId, true);
358347
return operation;
359348
});
360349
}
361350

351+
/** Every device refetches the tree. A node they did not have bumps the revision — the shape
352+
* moved; a visible turn reaching its terminal state keeps it — only its badge changed, and a
353+
* settle must not turn a peer's in-flight explicit-parent submit into a `conflict`. */
354+
private announceGraph(sessionId: SessionId, gainedNode: boolean): void {
355+
if (gainedNode) this.records.commitGraphShape(sessionId);
356+
const record = this.records.get(sessionId);
357+
if (record === undefined) return;
358+
this.transport.send(
359+
createWireMessage({
360+
kind: 'conversation.graph.changed',
361+
sessionId,
362+
graphRevision: record.graphRevision,
363+
...(record.activeLeafTurnId !== undefined && { activeLeafTurnId: record.activeLeafTurnId }),
364+
}),
365+
);
366+
}
367+
362368
/** {@link resolveFailed} for exit paths inside a session-scoped fiber: enqueued on the engine
363369
* task runner so an interrupting teardown never waits behind the store write. */
364370
resolveFailedDetached(
@@ -544,6 +550,7 @@ export class ConversationTurnService {
544550
this.settledAt.set(turn.sessionId, Date.now());
545551
this.runTask(
546552
storeOperation('conversation.turn.save', () => this.store.saveTurn({ ...turn, state })).pipe(
553+
Effect.tap(() => Effect.sync(() => this.announceGraph(turn.sessionId, false))),
547554
Effect.catch((error) =>
548555
Effect.logError(
549556
error.publicMessage,

0 commit comments

Comments
 (0)