blob: ea38fdfa2be33b87e9c031b04f5a3160d69453b9 [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 type {
HostedExecutionProjection,
HostedExecutionStartInput,
OperationKey,
OperationOutcome,
OperationOutput,
TurnSnapshot,
UsageQueryResult,
} from '../protocol/index.js';
import type { ConnectionContext, OperationHandlerMap } from './operation-dispatcher.js';
export interface HostHostedExecutionRunnerInput {
readonly handlers: Pick<
OperationHandlerMap,
'session.create' | 'turn.start' | 'turn.query' | 'turn.stop' | 'usage.query'
>;
readonly context: ConnectionContext;
readonly requestDrain: () => void;
readonly waitForExecutionResidencies: () => Promise<void>;
readonly waitForAllResidencies: () => Promise<void>;
readonly now?: () => number;
}
export class HostHostedExecutionRunner {
constructor(private readonly input: HostHostedExecutionRunnerInput) {}
async run(
input: HostedExecutionStartInput,
signal: AbortSignal,
): Promise<HostedExecutionProjection> {
const startedAt = (this.input.now ?? Date.now)();
let drainRequested = false;
const requestDrain = () => {
if (drainRequested) return;
drainRequested = true;
this.input.requestDrain();
};
const requestCancellation = () => requestDrain();
signal.addEventListener('abort', requestCancellation, { once: true });
if (signal.aborted) requestCancellation();
try {
signal.throwIfAborted();
await requireSuccess(
this.input.handlers['session.create'](
{ sessionId: input.executionId, ...input.session },
this.input.context,
),
);
signal.throwIfAborted();
const started = await requireSuccess(
this.input.handlers['turn.start'](
{
sessionId: input.executionId,
turnId: input.executionId,
content: input.content,
...(input.maxSteps === undefined ? {} : { maxSteps: input.maxSteps }),
},
this.input.context,
),
);
if (started.kind !== 'started') throw new Error('Hosted root Turn was not started');
const terminal = await this.#waitForTerminal(started.turn, signal);
await (signal.aborted
? this.input.waitForAllResidencies()
: this.input.waitForExecutionResidencies());
const usage = await this.#readUsage(startedAt, (this.input.now ?? Date.now)());
const incompleteUsage = incompleteUsageReason(usage);
if (incompleteUsage) {
return indeterminate(
input.executionId,
`Runtime Host usage did not settle: ${incompleteUsage}`,
);
}
return {
executionId: input.executionId,
kind: 'settled',
status: terminalStatus(terminal),
...(terminal.status === 'failed' ? { failureReason: terminal.failureClass } : {}),
usage: {
inputTokens: usage.summary.totalTokens.input,
outputTokens: usage.summary.totalTokens.output,
cacheReadTokens: usage.summary.totalTokens.cacheRead,
cacheWriteTokens: usage.summary.totalTokens.cacheWrite,
reasoningTokens: usage.summary.totalTokens.reasoning,
totalTokens: usage.summary.totalTokens.total,
},
costUsd:
usage.provenance.coverage.unpricedAttempts === 0 && usage.provenance.legacyRecords === 0
? usage.summary.totalCostUsd
: null,
};
} catch {
requestDrain();
await this.input.waitForAllResidencies().catch(() => undefined);
return indeterminate(input.executionId, 'Runtime Host could not settle execution');
} finally {
signal.removeEventListener('abort', requestCancellation);
}
}
async #waitForTerminal(started: TurnSnapshot, signal: AbortSignal): Promise<TurnSnapshot> {
let stop: Promise<TurnSnapshot> | undefined;
const requestStop = () => {
stop ??= requireSuccess(
this.input.handlers['turn.stop'](
{ sessionId: started.sessionId, turnId: started.turnId, runId: started.runId },
this.input.context,
),
);
};
signal.addEventListener('abort', requestStop, { once: true });
if (signal.aborted) requestStop();
try {
for (;;) {
if (stop) return await stop;
const snapshot = await requireSuccess(
this.input.handlers['turn.query'](
{ sessionId: started.sessionId, turnId: started.turnId },
this.input.context,
),
);
if (isTerminal(snapshot)) return snapshot;
await delay();
}
} finally {
signal.removeEventListener('abort', requestStop);
}
}
async #readUsage(from: number, to: number) {
const result = await requireSuccess(
this.input.handlers['usage.query'](
{ kind: 'summary', query: { range: { from, to } } },
this.input.context,
),
);
if (result.kind !== 'summary') throw new Error('Runtime Host returned non-summary usage');
return result;
}
}
function incompleteUsageReason(
result: Extract<UsageQueryResult, { kind: 'summary' }>,
): string | undefined {
const { coverage, unreadableRecords, pendingRepairs } = result.provenance;
if (unreadableRecords > 0) return 'unreadable_usage_record';
if (pendingRepairs > 0) return 'pending_usage_repair';
if (coverage.usagePartialAttempts > 0) return 'partial_attempt_usage';
if (coverage.usageMissingAttempts > 0) return 'missing_attempt_usage';
return undefined;
}
function isTerminal(
snapshot: TurnSnapshot,
): snapshot is TurnSnapshot & { status: 'completed' | 'failed' | 'cancelled' } {
return (
snapshot.status === 'completed' ||
snapshot.status === 'failed' ||
snapshot.status === 'cancelled'
);
}
function terminalStatus(snapshot: TurnSnapshot): 'completed' | 'failed' | 'cancelled' {
if (!isTerminal(snapshot)) throw new Error('Hosted root Turn is not terminal');
return snapshot.status;
}
async function requireSuccess<K extends OperationKey>(
outcome: Promise<OperationOutcome<K>>,
): Promise<OperationOutput<K>> {
const result = await outcome;
if (!result.ok) throw new Error(result.error.message);
return result.result;
}
function delay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 25));
}
function indeterminate(executionId: string, failureReason: string): HostedExecutionProjection {
return { executionId, kind: 'indeterminate', failureReason };
}