blob: 6460b836974ecde02ac1834e137f85101e7c3d89 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
decodeStoredMessage as decodePersistedStoredMessage,
type StoredMessage,
} from '@maka/core/session';
import { markPersisted } from '@maka/core/persisted-value';
import { type SessionEvent } from '@maka/core/events';
import {
createRuntimeHostSessionProjectionSeed,
RuntimeHostSessionProjector,
isRuntimeHostTerminalTurn as isTerminalTurn,
sameRuntimeHostTerminalTurn,
type RuntimeHostTerminalTurn as TerminalTurnSnapshot,
} from '@maka/runtime-host/adapter';
import {
isRuntimeHostReconnectingConnection,
RuntimeHostRequestInterruptedError,
RuntimeHostSubscriptionError,
type RuntimeHostConnection,
type RuntimeHostSessionSubscription,
} from '@maka/runtime-host/client';
import {
InteractionAnsweredSnapshot,
InteractionPendingSnapshot,
SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES,
SessionContinuitySnapshot,
SessionDomainChangedFrame,
SubscriptionFrame,
type GoalProjection,
} from '@maka/runtime-host/protocol';
import type { MakaPreparedSessionTurn } from './session-driver.js';
const decodeStoredMessage = (value: unknown): StoredMessage =>
decodePersistedStoredMessage(markPersisted<StoredMessage>(value));
const MAX_PENDING_FRAMES = 512;
const MAX_PENDING_EVENTS_PER_TURN = 1_024;
const LAG_REARM_PENDING_EVENTS = MAX_PENDING_EVENTS_PER_TURN / 2;
const MAX_RECOVERY_ATTEMPTS_WITHOUT_LIVE_FRAME = 8;
const RECOVERY_BACKOFF_INITIAL_MS = 25;
const RECOVERY_BACKOFF_MAX_MS = 500;
const RECOVERY_STABLE_AFTER_MS = 1_000;
export interface RuntimeHostSessionChannelOpenResult {
channel: RuntimeHostSessionChannel;
messages: StoredMessage[];
attachedTurnId?: string;
terminalTurn?: TerminalTurnSnapshot;
}
export interface RuntimeHostSessionChannelOptions {
connection: Pick<RuntimeHostConnection, 'openSessionSubscription'>;
/** Optional opener pinned to the concrete Host connection used for first attachment. */
openInitialSessionSubscription?: RuntimeHostConnection['openSessionSubscription'];
/** Cancels initial attachment, including transcript hydration and recovery. */
signal?: AbortSignal;
sessionId: string;
now: () => number;
onTurnStarted: (turn: MakaPreparedSessionTurn) => void;
onRuntimeResourceChanged: (sourceSessionId: string, ref: string) => void;
onSessionDomainChanged?: (frame: SessionDomainChangedFrame) => void;
onInteractionPending: (pending: InteractionPendingSnapshot) => void;
onInteractionResolved: (pending: InteractionPendingSnapshot) => void;
onTranscriptSettlement: (turnId: string) => void;
onTranscriptReplaced: (turnId: string, messages: readonly StoredMessage[]) => void;
/**
* Fired when the folded session projection's goal changes (set / settle /
* pause / resume / clear). The projection stream is the authoritative push
* channel for goal state — the same one the desktop observer diffs.
*/
onGoalChanged: (goal: GoalProjection | null) => void;
/** Optional read-only projection observer; the channel remains the sole folder. */
onSnapshotChanged?: (snapshot: SessionContinuitySnapshot) => void;
/** Fired only after the channel's bounded recovery policy is exhausted. */
onFailed?: (error: Error) => void;
onRecovered: () => void;
}
export class RuntimeHostSessionChannel {
readonly sessionId: string;
readonly messages: StoredMessage[];
readonly #connection: Pick<RuntimeHostConnection, 'openSessionSubscription'>;
#subscription: RuntimeHostSessionSubscription;
readonly #now: () => number;
readonly #onTurnStarted: (turn: MakaPreparedSessionTurn) => void;
readonly #onRuntimeResourceChanged: (sourceSessionId: string, ref: string) => void;
readonly #onSessionDomainChanged: ((frame: SessionDomainChangedFrame) => void) | undefined;
readonly #onInteractionPending: (pending: InteractionPendingSnapshot) => void;
readonly #onInteractionResolved: (pending: InteractionPendingSnapshot) => void;
readonly #onTranscriptSettlement: (turnId: string) => void;
readonly #onTranscriptReplaced: (turnId: string, messages: readonly StoredMessage[]) => void;
readonly #onGoalChanged: (goal: GoalProjection | null) => void;
readonly #onSnapshotChanged: ((snapshot: SessionContinuitySnapshot) => void) | undefined;
readonly #onFailed: ((error: Error) => void) | undefined;
readonly #onRecovered: () => void;
readonly #turns = new Map<string, SessionEventQueue>();
readonly #pendingFrames: SubscriptionFrame[] = [];
readonly #pendingStartedTurns = new Map<string, MakaPreparedSessionTurn>();
readonly #pendingOpenedInteractions: InteractionPendingSnapshot[] = [];
readonly #pendingResolvedInteractions: InteractionPendingSnapshot[] = [];
readonly #pendingTranscriptSettlements: string[] = [];
readonly #failedSubscriptions = new WeakSet<RuntimeHostSessionSubscription>();
readonly #retiringSubscriptions = new WeakSet<RuntimeHostSessionSubscription>();
#projector: RuntimeHostSessionProjector | undefined;
#ready = false;
#activated = false;
#startedTurnBarrier: string | undefined;
#closing = false;
readonly #closeController = new AbortController();
#closeTask: Promise<void> | undefined;
#failure: Error | undefined;
#recoveryTask: Promise<void> | undefined;
#recoveryAttemptsWithoutLiveFrame = 0;
#recoveryAwaitingLiveFrame: RuntimeHostSessionSubscription | undefined;
#recoveryStableTimer: ReturnType<typeof setTimeout> | undefined;
private constructor(
subscription: RuntimeHostSessionSubscription,
messages: StoredMessage[],
options: Omit<RuntimeHostSessionChannelOptions, 'connection' | 'sessionId'>,
connection: Pick<RuntimeHostConnection, 'openSessionSubscription'>,
) {
this.#connection = connection;
this.#subscription = subscription;
this.sessionId = subscription.snapshot.session.sessionId;
this.messages = messages;
this.#now = options.now;
this.#onTurnStarted = options.onTurnStarted;
this.#onRuntimeResourceChanged = options.onRuntimeResourceChanged;
this.#onSessionDomainChanged = options.onSessionDomainChanged;
this.#onInteractionPending = options.onInteractionPending;
this.#onInteractionResolved = options.onInteractionResolved;
this.#onTranscriptSettlement = options.onTranscriptSettlement;
this.#onTranscriptReplaced = options.onTranscriptReplaced;
this.#onGoalChanged = options.onGoalChanged;
this.#onSnapshotChanged = options.onSnapshotChanged;
this.#onFailed = options.onFailed;
this.#onRecovered = options.onRecovered;
this.#subscribeSessionDomainChanges(subscription);
}
#subscribeSessionDomainChanges(subscription: RuntimeHostSessionSubscription): void {
if (!this.#onSessionDomainChanged) return;
subscription.subscribeSessionDomainChanges((frame) => {
if (!this.#closing && this.#subscription === subscription) {
this.#onSessionDomainChanged?.(frame);
}
});
}
static async open(
options: RuntimeHostSessionChannelOptions,
): Promise<RuntimeHostSessionChannelOpenResult> {
const openInitial =
options.openInitialSessionSubscription ??
options.connection.openSessionSubscription.bind(options.connection);
const subscription = await runChannelOperation(
() =>
openInitial({
sessionId: options.sessionId,
transcript: {
kind: 'tail',
maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES,
},
}),
options.signal,
(lateSubscription) => lateSubscription.close(),
);
const initialRoot = structuredClone(subscription.snapshot.rootTurn);
const channel = new RuntimeHostSessionChannel(subscription, [], options, options.connection);
const onAbort = () => {
void channel.close().catch(() => undefined);
};
options.signal?.addEventListener('abort', onAbort, { once: true });
if (options.signal?.aborted) onAbort();
void channel.#pump(subscription);
try {
const recovered = await channel.#hydrateInitial(subscription);
options.signal?.throwIfAborted();
const root = recovered ? structuredClone(channel.snapshot.rootTurn) : initialRoot;
return {
channel,
messages: channel.messages.map((message) => structuredClone(message)),
...(root && !isTerminalTurn(root) ? { attachedTurnId: root.turnId } : {}),
...(root && isTerminalTurn(root) ? { terminalTurn: root } : {}),
};
} catch (error) {
await channel.close().catch(() => undefined);
throw error;
} finally {
options.signal?.removeEventListener('abort', onAbort);
}
}
async #hydrateInitial(subscription: RuntimeHostSessionSubscription): Promise<boolean> {
let messages: StoredMessage[] | undefined;
try {
messages = await runChannelOperation(
() => subscription.loadTranscript(decodeStoredMessage),
this.#closeController.signal,
);
this.#closeController.signal.throwIfAborted();
} catch (error) {
if (!this.#canRecover(error)) throw error;
this.#failedSubscriptions.add(subscription);
}
if (this.#failedSubscriptions.has(subscription)) {
await this.#recover(subscription);
if (!this.#ready) {
throw this.#failure ?? new Error('Runtime Host Session recovery ended before hydration');
}
return true;
}
this.#acceptCanonicalReplacement(messages ?? []);
this.#ready = true;
try {
for (const frame of this.#pendingFrames.splice(0)) this.#accept(frame);
} catch (error) {
if (!this.#canRecover(error)) throw error;
this.#failedSubscriptions.add(subscription);
await this.#recover(subscription);
if (!this.#ready) {
throw this.#failure ?? new Error('Runtime Host Session recovery ended before hydration');
}
return true;
}
return false;
}
async *eventsForTurn(turnId: string): AsyncIterable<SessionEvent> {
try {
yield* this.#queue(turnId);
} finally {
if (this.#startedTurnBarrier === turnId) {
this.#startedTurnBarrier = undefined;
if (!this.#closing) this.#flushStartedTurns();
}
}
}
get failed(): boolean {
return this.#failure !== undefined;
}
get snapshot(): SessionContinuitySnapshot {
return this.#projector?.snapshot ?? this.#subscription.snapshot;
}
get firstObservedTurnId(): string | undefined {
return this.#pendingStartedTurns.keys().next().value;
}
activate(claimedTurnId?: string): void {
if (this.#closing || this.#activated) return;
this.#activated = true;
if (claimedTurnId) {
this.#pendingStartedTurns.delete(claimedTurnId);
this.#startedTurnBarrier = claimedTurnId;
} else {
this.#flushStartedTurns();
}
for (const interaction of this.snapshot.interactions.pending) {
this.#onInteractionPending(structuredClone(interaction));
}
for (const interaction of this.#pendingOpenedInteractions.splice(0)) {
this.#onInteractionPending(interaction);
}
for (const interaction of this.#pendingResolvedInteractions.splice(0)) {
this.#onInteractionResolved(interaction);
}
for (const turnId of this.#pendingTranscriptSettlements.splice(0)) {
this.#onTranscriptSettlement(turnId);
}
}
#flushStartedTurns(): void {
for (const turn of this.#pendingStartedTurns.values()) this.#onTurnStarted(turn);
this.#pendingStartedTurns.clear();
}
seedTerminalCut(turn: TerminalTurnSnapshot): void {
if (!this.#projector) return;
for (const event of this.#projector.seedTerminal(turn)) this.#emit(event);
this.#queue(turn.turnId).finish();
}
failTurn(turnId: string, error: unknown): void {
this.#queue(turnId).fail(error);
}
pendingInteraction(interactionId: string): InteractionPendingSnapshot | undefined {
return this.snapshot.interactions.pending.find(
(interaction) => interaction.interactionId === interactionId,
);
}
publishInteractionAnswer(
answered: InteractionAnsweredSnapshot,
pending: InteractionPendingSnapshot,
): void {
const base = {
id: `host-interaction:${answered.interactionId}:${answered.revision}`,
turnId: answered.turnId,
ts: this.#now(),
requestId: answered.interactionId,
toolUseId:
pending.request.kind === 'sandbox_boundary'
? pending.interactionId
: pending.request.toolUseId,
};
if (answered.outcome.kind === 'question_answer') {
this.#emit({ type: 'user_question_answer_ack', ...base });
} else if (answered.outcome.kind === 'form_answer') {
this.#emit({ type: 'form_answer_ack', ...base });
} else if (answered.outcome.kind === 'sandbox_boundary_decision') {
this.#emit({
type: 'sandbox_boundary_decision_ack',
...base,
decision: answered.outcome.decision,
status: answered.outcome.status,
revision: answered.revision,
});
}
}
close(): Promise<void> {
this.#closeTask ??= this.#close();
return this.#closeTask;
}
async #close(): Promise<void> {
this.#closing = true;
this.#closeController.abort(new Error('Runtime Host Session channel is closed'));
this.#clearRecoveryStableTimer();
this.#recoveryAwaitingLiveFrame = undefined;
this.#pendingStartedTurns.clear();
for (const queue of this.#turns.values()) queue.finish();
await this.#subscription.close();
}
async #pump(subscription: RuntimeHostSessionSubscription): Promise<void> {
try {
for await (const frame of subscription) {
if (this.#closing || this.#subscription !== subscription) return;
if (!this.#ready) {
if (this.#pendingFrames.length >= MAX_PENDING_FRAMES) {
throw new RuntimeHostSubscriptionError(
'slow_consumer',
'Runtime Host transcript could not keep up with live Session events',
);
}
this.#pendingFrames.push(frame);
} else {
this.#accept(frame);
if (frame.kind !== 'subscription.closed') this.#observeRecoveryLiveFrame(subscription);
}
}
// A stream that ends without a subscription.closed frame is a broken
// live channel, not a terminal state: the Host may have torn the
// subscription down mid-recovery (e.g. slow_consumer eviction while the
// transcript reload was still buffering). Route it through the same
// resync recovery as an explicit close instead of killing the channel.
if (!this.#closing)
throw new RuntimeHostSubscriptionError(
'connection_closed',
'Runtime Host Session subscription ended unexpectedly',
);
} catch (error) {
if (this.#closing || this.#subscription !== subscription) return;
// A subscription retired because a turn consumer fell behind is closed
// deliberately; its pump must not turn that expected close into a
// channel failure. The guard also drops a genuine error racing the
// deliberate close on this pump; that is safe because the replacement
// subscription's own pump and recovery path re-surface any real
// failure through #fail.
if (this.#retiringSubscriptions.has(subscription)) return;
if (this.#canRecover(error)) {
this.#failedSubscriptions.add(subscription);
if (!this.#ready) return;
if (this.#recoveryTask) {
const schedule = () => {
if (this.#subscription === subscription && !this.#closing) {
this.#scheduleRecovery(subscription);
}
};
void this.#recoveryTask.then(schedule, schedule);
} else {
this.#scheduleRecovery(subscription);
}
return;
}
this.#fail(error);
}
}
#scheduleRecovery(failed: RuntimeHostSessionSubscription): void {
if (this.#closing || this.#failure || this.#subscription !== failed || this.#recoveryTask)
return;
this.#clearRecoveryStableTimer();
const task = this.#recover(failed);
this.#recoveryTask = task;
void task
.catch((error: unknown) => {
if (!this.#closing) this.#fail(error);
})
.finally(() => {
if (this.#recoveryTask === task) this.#recoveryTask = undefined;
});
}
async #recover(failed: RuntimeHostSessionSubscription): Promise<void> {
let previous = failed;
while (!this.#closing && !this.#failure && this.#subscription === previous) {
if (this.#recoveryAwaitingLiveFrame === previous) {
this.#recoveryAwaitingLiveFrame = undefined;
}
await previous.close().catch(() => undefined);
await this.#waitForRecoveryAttempt();
if (this.#closing || this.#failure || this.#subscription !== previous) return;
let replacement: RuntimeHostSessionSubscription;
try {
replacement = await runChannelOperation(
() =>
this.#connection.openSessionSubscription({
sessionId: this.sessionId,
transcript: {
kind: 'tail',
maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES,
},
}),
this.#closeController.signal,
(lateSubscription) => lateSubscription.close(),
);
} catch (error) {
if (this.#canRecover(error)) continue;
throw error;
}
if (this.#closing || this.#failure || this.#subscription !== previous) {
await replacement.close().catch(() => undefined);
return;
}
this.#subscription = replacement;
this.#subscribeSessionDomainChanges(replacement);
this.#ready = false;
this.#pendingFrames.length = 0;
void this.#pump(replacement);
try {
const messages = await runChannelOperation(
() => replacement.loadTranscript(decodeStoredMessage),
this.#closeController.signal,
);
if (this.#failedSubscriptions.has(replacement)) {
throw new RuntimeHostSubscriptionError(
'connection_closed',
'Runtime Host Session subscription closed during catch-up',
);
}
if (this.#closing || this.#failure || this.#subscription !== replacement) return;
const replacedLiveState = this.#acceptCanonicalReplacement(messages);
this.#recoveryAwaitingLiveFrame = replacement;
this.#ready = true;
for (const frame of this.#pendingFrames.splice(0)) this.#accept(frame);
if (replacedLiveState) this.#onRecovered();
return;
} catch (error) {
if (!this.#canRecover(error)) throw error;
previous = replacement;
}
}
}
#acceptCanonicalReplacement(messages: StoredMessage[]): boolean {
const replacedLiveState = this.#projector !== undefined;
const previousSnapshot = this.snapshot;
const nextSnapshot = structuredClone(this.#subscription.snapshot);
this.messages.splice(
0,
this.messages.length,
...messages.map((message) => structuredClone(message)),
);
if (!sameGoalProjection(previousSnapshot.goal, nextSnapshot.goal)) {
this.#onGoalChanged(nextSnapshot.goal === null ? null : structuredClone(nextSnapshot.goal));
}
this.#projector = new RuntimeHostSessionProjector(
nextSnapshot,
createRuntimeHostSessionProjectionSeed(this.messages, nextSnapshot),
this.#now,
this.#subscription.activeAssistantStreams,
);
this.#onSnapshotChanged?.(structuredClone(this.#projector.snapshot));
// A canonical replacement is a sequence cut. No queued event from the
// retired subscription may replay after the transcript/snapshot has
// established newer state; active, terminal, and interaction state is
// seeded again below from the replacement authority.
for (const queue of this.#turns.values()) queue.cutBacklog();
if (!replacedLiveState) {
for (const event of this.#projector.seedActive(false)) this.#emit(event);
return false;
}
const previousRoot = previousSnapshot.rootTurn;
const root = nextSnapshot.rootTurn;
const transcriptTurnId = root?.turnId ?? previousRoot?.turnId;
if (transcriptTurnId) {
this.#onTranscriptReplaced(
transcriptTurnId,
this.messages.map((message) => structuredClone(message)),
);
}
const previousPending = new Map(
previousSnapshot.interactions.pending.map((pending) => [pending.interactionId, pending]),
);
const nextPendingIds = new Set(
nextSnapshot.interactions.pending.map((pending) => pending.interactionId),
);
for (const pending of previousPending.values()) {
if (nextPendingIds.has(pending.interactionId)) continue;
if (this.#activated) this.#onInteractionResolved(structuredClone(pending));
else this.#pendingResolvedInteractions.push(structuredClone(pending));
}
for (const pending of nextSnapshot.interactions.pending) {
if (previousPending.has(pending.interactionId)) continue;
const copy = structuredClone(pending);
if (this.#activated) this.#onInteractionPending(copy);
else this.#pendingOpenedInteractions.push(copy);
}
if (
previousRoot &&
!isTerminalTurn(previousRoot) &&
(!root || root.runId !== previousRoot.runId)
) {
const terminalEvents = this.#projector.seedStoredTerminal(previousRoot.turnId, this.messages);
if (terminalEvents.length === 0) {
throw new RuntimeHostSubscriptionError(
'projection_revision_invalid',
`Runtime Host replacement omitted the terminal record for Turn ${previousRoot.turnId}`,
);
}
for (const event of terminalEvents) this.#emit(event);
this.#queue(previousRoot.turnId).finish();
}
if (root && !isTerminalTurn(root)) {
for (const event of this.#projector.seedActive(false)) this.#emit(event);
if (!previousRoot || previousRoot.runId !== root.runId) {
const turn = {
sessionId: this.sessionId,
turnId: root.turnId,
runId: root.runId,
events: this.eventsForTurn(root.turnId),
} satisfies MakaPreparedSessionTurn;
if (this.#activated && !this.#startedTurnBarrier) this.#onTurnStarted(turn);
else this.#pendingStartedTurns.set(turn.turnId, turn);
}
} else if (root && isTerminalTurn(root) && !sameRuntimeHostTerminalTurn(previousRoot, root)) {
for (const event of this.#projector.seedTerminal(root)) this.#emit(event);
this.#queue(root.turnId).finish();
if (this.#activated) this.#onTranscriptSettlement(root.turnId);
else this.#pendingTranscriptSettlements.push(root.turnId);
}
return true;
}
async #waitForRecoveryAttempt(): Promise<void> {
if (this.#recoveryAttemptsWithoutLiveFrame >= MAX_RECOVERY_ATTEMPTS_WITHOUT_LIVE_FRAME) {
throw new RuntimeHostSubscriptionError(
'connection_closed',
'Runtime Host Session subscription recovery exhausted its retry budget',
);
}
if (this.#recoveryAttemptsWithoutLiveFrame > 0) {
const delayMs = Math.min(
RECOVERY_BACKOFF_INITIAL_MS * 2 ** (this.#recoveryAttemptsWithoutLiveFrame - 1),
RECOVERY_BACKOFF_MAX_MS,
);
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
this.#recoveryAttemptsWithoutLiveFrame += 1;
}
#scheduleRecoveryStable(subscription: RuntimeHostSessionSubscription): void {
this.#clearRecoveryStableTimer();
const timer = setTimeout(() => {
if (!this.#closing && this.#subscription === subscription && this.#ready) {
this.#recoveryAttemptsWithoutLiveFrame = 0;
}
if (this.#recoveryStableTimer === timer) this.#recoveryStableTimer = undefined;
}, RECOVERY_STABLE_AFTER_MS);
timer.unref?.();
this.#recoveryStableTimer = timer;
}
#observeRecoveryLiveFrame(subscription: RuntimeHostSessionSubscription): void {
if (this.#recoveryAwaitingLiveFrame !== subscription) return;
this.#recoveryAwaitingLiveFrame = undefined;
this.#scheduleRecoveryStable(subscription);
}
#clearRecoveryStableTimer(): void {
if (this.#recoveryStableTimer !== undefined) clearTimeout(this.#recoveryStableTimer);
this.#recoveryStableTimer = undefined;
}
#canRecover(error: unknown): boolean {
if (!isRuntimeHostReconnectingConnection(this.#connection)) return false;
if (error instanceof RuntimeHostRequestInterruptedError) {
return error.reason === 'connection_lost';
}
return (
error instanceof RuntimeHostSubscriptionError &&
(error.reason === 'connection_closed' ||
error.reason === 'sequence_gap' ||
error.reason === 'projection_revision_invalid' ||
error.reason === 'transcript_release_failed' ||
error.reason === 'slow_consumer')
);
}
#accept(frame: SubscriptionFrame): void {
if (frame.kind === 'subscription.session_domain_changed') {
if (frame.domain === 'runtime_resource') {
for (const resource of frame.resources) {
this.#onRuntimeResourceChanged(resource.sourceSessionId, resource.ref);
}
}
return;
}
if (frame.kind === 'subscription.closed') {
if (frame.reason === 'slow_consumer') {
throw new RuntimeHostSubscriptionError(
'slow_consumer',
'Runtime Host Session subscription consumer fell behind',
);
}
this.#fail(new Error(`Runtime Host Session subscription closed: ${frame.reason}`));
return;
}
const previousSnapshot = this.snapshot;
const previousPendingIds = new Set(
previousSnapshot.interactions.pending.map((interaction) => interaction.interactionId),
);
const previousGoal = previousSnapshot.goal;
const update = this.#projector?.accept(frame);
if (!update || !this.#projector) return;
const snapshot = this.#projector.snapshot;
this.#onSnapshotChanged?.(structuredClone(snapshot));
if (!sameGoalProjection(previousGoal, snapshot.goal)) {
// Clone like the canonical-replacement path above: listeners receive
// their own copy, so a mutating listener cannot corrupt the live
// snapshot regardless of which path delivered the change.
this.#onGoalChanged(snapshot.goal === null ? null : structuredClone(snapshot.goal));
}
for (const interaction of snapshot.interactions.pending) {
if (previousPendingIds.has(interaction.interactionId)) continue;
const pending = structuredClone(interaction);
if (this.#activated) this.#onInteractionPending(pending);
else this.#pendingOpenedInteractions.push(pending);
}
for (const interaction of update.resolvedInteractions) {
if (this.#activated) this.#onInteractionResolved(interaction);
else this.#pendingResolvedInteractions.push(interaction);
}
for (const event of update.events) this.#emit(event);
if (update.startedTurn && !isTerminalTurn(update.startedTurn)) {
const turn = {
sessionId: this.sessionId,
turnId: update.startedTurn.turnId,
runId: update.startedTurn.runId,
events: this.eventsForTurn(update.startedTurn.turnId),
} satisfies MakaPreparedSessionTurn;
if (this.#activated && !this.#startedTurnBarrier) this.#onTurnStarted(turn);
else this.#pendingStartedTurns.set(turn.turnId, turn);
}
if (update.terminalTurn) {
this.#queue(update.terminalTurn.turnId).finish();
if (this.#activated) this.#onTranscriptSettlement(update.terminalTurn.turnId);
else this.#pendingTranscriptSettlements.push(update.terminalTurn.turnId);
}
}
#emit(event: SessionEvent): void {
if (event.type === 'tool_result') this.#onTranscriptSettlement(event.turnId);
this.#queue(event.turnId).push(event);
}
#queue(turnId: string): SessionEventQueue {
let queue = this.#turns.get(turnId);
if (!queue) {
queue = new SessionEventQueue(() => this.#noteTurnConsumerLagging());
this.#turns.set(turnId, queue);
if (this.#failure) queue.fail(this.#failure);
}
return queue;
}
/**
* A turn consumer that cannot keep up is a slow client: retire the healthy
* subscription through the same recovery path a Host eviction would take so
* the session re-syncs from canonical state instead of dying mid-turn.
*
* Note this escalates a single lagging queue to a session-wide recovery.
* That is a benign superset even when the lagging queue belongs to an
* abandoned old turn: the resync heals every turn's state, and the
* per-queue lag latch plus hysteresis keep a wedged consumer from looping
* resubscribes.
*/
#noteTurnConsumerLagging(): void {
if (this.#closing || this.#failure || !this.#ready) return;
const subscription = this.#subscription;
this.#retiringSubscriptions.add(subscription);
this.#scheduleRecovery(subscription);
}
#fail(error: unknown): void {
if (this.#failure) return;
this.#failure = error instanceof Error ? error : new Error(String(error));
for (const queue of this.#turns.values()) queue.fail(this.#failure);
this.#onFailed?.(this.#failure);
}
}
class SessionEventQueue implements AsyncIterable<SessionEvent>, AsyncIterator<SessionEvent> {
readonly #items: SessionEvent[] = [];
readonly #onLag: () => void;
#waiting:
| {
resolve(value: IteratorResult<SessionEvent>): void;
reject(error: unknown): void;
}
| undefined;
#done = false;
#finishAfterItems = false;
#error: unknown;
#lagging = false;
constructor(onLag: () => void) {
this.#onLag = onLag;
}
[Symbol.asyncIterator](): AsyncIterator<SessionEvent> {
return this;
}
next(): Promise<IteratorResult<SessionEvent>> {
const item = this.#items.shift();
if (item) {
// Re-arm with hysteresis: a consumer that has drained half the backlog
// is making progress, so a later lag episode may trigger another
// recovery; a wedged consumer never drains and cannot loop resubscribes.
if (this.#items.length <= LAG_REARM_PENDING_EVENTS) this.#lagging = false;
return Promise.resolve({ done: false, value: item });
}
if (this.#error !== undefined) return Promise.reject(this.#error);
if (this.#done || this.#finishAfterItems) {
this.#done = true;
return Promise.resolve({ done: true, value: undefined });
}
if (this.#waiting)
return Promise.reject(new Error('Session event stream already has a reader'));
// A canonical cut can empty a lagged queue before its consumer resumes.
// Waiting again proves the consumer caught up and may arm a later episode.
this.#lagging = false;
return new Promise((resolve, reject) => {
this.#waiting = { resolve, reject };
});
}
push(event: SessionEvent): void {
if (this.#done || this.#finishAfterItems || this.#error !== undefined) return;
if (this.#waiting) {
const waiting = this.#waiting;
this.#waiting = undefined;
waiting.resolve({ done: false, value: event });
return;
}
if (this.#items.length >= MAX_PENDING_EVENTS_PER_TURN) {
// A consumer that falls behind must not kill the stream. Shed deltas
// (text/thinking ranges are healed by the next canonical resync or
// completion; tool_output_delta chunks are seq-deduped transient UI
// updates healed by the terminal tool_result and the durable
// transcript) and make room for every other event; the channel
// resubscribes to re-sync state, like the Desktop subscription owner
// does (#2630).
if (isSheddableDelta(event)) {
this.#noteLag();
return;
}
const shedIndex = this.#items.findIndex(isSheddableDelta);
if (shedIndex !== -1) {
this.#items.splice(shedIndex, 1);
} else if (isGuaranteedOutcome(event)) {
// Turn-terminal outcomes and tool results always land, even when the
// backlog holds no delta to evict: without a turn outcome the
// consumer reaches end-of-stream without a result, and without a
// tool_result the live tool card stays running until the durable
// transcript heals it. The oldest queued event is sacrificed.
this.#items.shift();
} else {
// A non-delta, non-outcome event with nothing sheddable to evict
// (e.g. a tool_call begin behind an all-control backlog) is dropped;
// the durable transcript heals the final state. Documented boundary
// for v1.
this.#noteLag();
return;
}
this.#noteLag();
}
this.#items.push(event);
}
/** Drop unseen pre-cut work, retaining an unconsumed terminal guarantee. */
cutBacklog(): void {
const terminal = this.#finishAfterItems ? this.#items.find(isTurnTerminalOutcome) : undefined;
this.#items.length = 0;
if (terminal) this.#items.push(terminal);
}
#noteLag(): void {
if (this.#lagging) return;
this.#lagging = true;
this.#onLag();
}
finish(): void {
if (this.#done || this.#error !== undefined) return;
this.#finishAfterItems = true;
if (this.#items.length === 0) {
this.#done = true;
this.#waiting?.resolve({ done: true, value: undefined });
this.#waiting = undefined;
}
}
fail(error: unknown): void {
if (this.#done || this.#error !== undefined) return;
this.#error = error;
this.#items.length = 0;
this.#waiting?.reject(error);
this.#waiting = undefined;
}
}
function isSheddableDelta(event: SessionEvent): boolean {
// tool_output_delta is sheddable by design: its chunks are transient UI
// updates with a monotonic per-tool `seq` (renderers de-dupe and order by
// it, so a shed range leaves a gap, never corruption), and the terminal
// tool_result plus the durable transcript heal the tool's final output.
return (
event.type === 'text_delta' ||
event.type === 'thinking_delta' ||
event.type === 'tool_output_delta'
);
}
function isGuaranteedOutcome(event: SessionEvent): boolean {
// complete/abort/error close the turn; text/thinking completion carries the
// authoritative assistant accumulator; tool_result is the authoritative
// terminal result for its tool. Losing any of them leaves a consumer with
// an incomplete outcome even though the projector has already settled it.
return (
isTurnTerminalOutcome(event) ||
event.type === 'text_complete' ||
event.type === 'thinking_complete' ||
event.type === 'tool_result'
);
}
function isTurnTerminalOutcome(event: SessionEvent): boolean {
return event.type === 'complete' || event.type === 'abort' || event.type === 'error';
}
function runChannelOperation<T>(
operation: () => Promise<T>,
signal?: AbortSignal,
discard?: (value: T) => Promise<void>,
): Promise<T> {
if (!signal) return operation();
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((resolve, reject) => {
let aborted = false;
const onAbort = () => {
aborted = true;
signal.removeEventListener('abort', onAbort);
reject(signal.reason);
};
signal.addEventListener('abort', onAbort, { once: true });
let running: Promise<T>;
try {
running = operation();
} catch (error) {
signal.removeEventListener('abort', onAbort);
reject(error);
return;
}
void running.then(
(value) => {
signal.removeEventListener('abort', onAbort);
// The connection may finish opening after this channel has gone away.
// A late subscription still belongs to the operation and must be closed.
if (aborted) void discard?.(value).catch(() => undefined);
else resolve(value);
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
});
}
/**
* Goal identity + revision: GoalManager.commit bumps the revision on every
* accepted transition, so this pair detects every set/settle/pause/resume/
* clear without a field-by-field compare.
*/
function sameGoalProjection(a: GoalProjection | null, b: GoalProjection | null): boolean {
if (a === null || b === null) return a === b;
return a.goalId === b.goalId && a.revision === b.revision;
}