blob: 5ef77a2318a0ce7d93aca65605a1cd5e7c2295ae [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 { isInteractionAnswerValidForRequest } from '@maka/core/interaction';
import type { StoredMessage } from '@maka/core/session';
import {
abortable,
readRuntimeHostConnectionCatalog,
type RuntimeHostConnection,
} from '@maka/runtime-host/client';
import { updateRuntimeHostSession } from './runtime-host-session-update.js';
import type {
InteractionPendingSnapshot,
SessionConfigurationPatch,
SessionCatalogProjection,
SessionContinuitySnapshot,
} from '@maka/runtime-host/protocol';
import { RuntimeHostSessionChannel } from './runtime-host-session-channel.js';
import type { MakaRunEnvironmentDeps, MakaRunOptions } from './run-command-core.js';
import { RunSessionInterrupted, type MakaRunSessionResult } from './run-session-result.js';
import { TurnOutcomeClassifier, acceptStoredTurn } from './run-turn-outcome.js';
/** Session controls that must verify ownership before mutating a Host-owned Turn. */
export function sessionControlRequiresExecutionBoundary(
options: Pick<
MakaRunOptions,
'prompt' | 'answers' | 'resumeExecution' | 'sandboxDecision' | 'stop'
>,
): boolean {
return (
options.prompt !== undefined ||
options.answers !== undefined ||
options.resumeExecution === true ||
options.sandboxDecision !== undefined ||
options.stop === true
);
}
/** Session commands observe Host-owned execution; they never publish client MCP. */
export async function runMakaSessionCommand(
connection: RuntimeHostConnection,
options: MakaRunOptions,
env: Pick<MakaRunEnvironmentDeps, 'onSigint' | 'newId'>,
allowDetach = true,
): Promise<MakaRunSessionResult> {
const sessionId = options.sessionId!;
const executing =
options.prompt !== undefined || options.answers !== undefined || options.resumeExecution;
const decidingSandbox = options.sandboxDecision !== undefined;
const controllingTurn = sessionControlRequiresExecutionBoundary(options);
if ((executing || decidingSandbox) && !allowDetach)
throw new Error(
'Session control execution requires a persistent installation; use the blocking legacy run path for temporary npx invocations',
);
// Never silently discard options that the old turn.start path supports.
if (options.graph || options.cwd || options.projectId || options.timeoutMs) {
throw new Error(
'Session workspace, timeout and Graph overrides require the blocking legacy --resume <id> path',
);
}
if (
options.maxSteps !== undefined &&
(options.prompt === undefined || options.placement !== undefined)
) {
throw new Error('--max-steps requires an idle Session prompt without --queue/--steer');
}
const cancelled = new AbortController();
const removeSigint = env.onSigint(() => cancelled.abort(new RunSessionInterrupted()));
const observer = await SessionRunObserver.open(connection, sessionId, cancelled.signal).catch(
(error) => {
removeSigint();
throw error;
},
);
const assertActive = () => {
cancelled.signal.throwIfAborted();
observer.throwIfFailed();
};
try {
assertActive();
const configured =
options.model !== undefined ||
options.connection !== undefined ||
options.thinking !== undefined
? await configureSessionModel(connection, options, assertActive)
: undefined;
const configuration = configured
? {
connection: configured.llmConnectionSlug,
model: configured.model,
thinking: configured.thinkingLevel ?? null,
}
: undefined;
let target = observer.snapshot.rootTurn?.turnId;
if (options.recap) {
const result = await abortable(
() =>
connection.request('session.recap.generate', {
sessionId,
effectId: env.newId(),
reason: 'manual',
}),
cancelled.signal,
);
assertActive();
if (result.kind === 'failed') throw new Error(`Session recap failed: ${result.errorClass}`);
const executionBoundary = options.status
? await connection.request('session.execution_boundary.query', { sessionId })
: undefined;
assertActive();
return {
...observer.status(),
recap: result.text,
...(configuration ? { configuration } : {}),
...(executionBoundary ? { executionBoundary } : {}),
};
}
let messageId: string | undefined;
let submission: MakaRunSessionResult['submission'];
let admittedQueueRevision: number | undefined;
let admittedQueueEpoch: string | undefined;
let fullAccess = false;
if (controllingTurn) {
const boundary = await connection.request('session.execution_boundary.query', { sessionId });
assertActive();
if (boundary.kind === 'external')
throw new Error('This Session must be controlled by its owning harness');
fullAccess = boundary.kind === 'bypass';
}
const authorizeExecution = async () => {
assertActive();
if (!options.yolo || fullAccess) return;
await updateRuntimeHostSession(
connection,
sessionId,
(current) =>
connection.request('session.configuration.update', {
sessionId,
expectedRevision: current.revision,
patch: { permissionMode: 'bypass' },
}),
{ operation: 'session.configuration.update', assertRequestAllowed: assertActive },
);
assertActive();
};
assertActive();
if (options.stop) {
const root = observer.snapshot.rootTurn;
if (!root || terminal(root.status)) return observer.status();
const stopped = await connection.request('turn.stop', {
sessionId,
turnId: root.turnId,
runId: root.runId,
});
return {
sessionId,
status: terminal(stopped.status)
? stopped.status === 'completed'
? 'completed'
: 'failed'
: 'running',
};
}
if (decidingSandbox) {
const pending = observer.approvals();
if (pending.length !== 1)
throw new Error(
`Expected one pending sandbox request, found ${pending.length}; no decision was submitted`,
);
const request = pending[0]!;
target = request.turnId;
assertActive();
// Submit only the identity observed by this invocation. Never retry an uncertain decision.
await connection.request('interaction.answer', {
sessionId,
interactionId: request.interactionId,
answer: { kind: 'sandbox_boundary', decision: options.sandboxDecision! },
});
observer.ignoreAnswered(request.interactionId);
} else if (options.answers !== undefined) {
const pending = observer.snapshot.interactions.pending.filter(
(p) => p.request.kind === 'question',
);
if (pending.length !== 1)
throw new Error(
`Expected one pending AskUser batch, found ${pending.length}; no answer was submitted`,
);
const question = pending[0]!;
if (
!isInteractionAnswerValidForRequest(question.request, {
kind: 'question',
answers: options.answers,
})
)
throw new Error(
'Answers do not match the pending AskUser questions; no answer was submitted',
);
await authorizeExecution();
target = question.turnId;
// Use the observed identity exactly once. A dispatched answer is never retried here.
await connection.request('interaction.answer', {
sessionId,
interactionId: question.interactionId,
answer: { kind: 'question', answers: options.answers },
});
observer.ignoreAnswered(question.interactionId);
} else if (options.resumeExecution) {
const plan = await connection.request('turn.resume.query', { sessionId });
assertActive();
if (plan.disposition !== 'ready') throw new Error(`Execution cannot resume: ${plan.reason}`);
await authorizeExecution();
const resumed = await connection.request('turn.resume.start', {
sessionId,
turnId: env.newId(),
sourceRunId: plan.sourceRunId,
sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater,
});
if (resumed.kind !== 'started')
throw new Error(`Execution cannot resume: ${resumed.plan.reason}`);
target = resumed.turn.turnId;
} else if (options.prompt !== undefined) {
if (options.maxSteps !== undefined) {
const root = observer.snapshot.rootTurn;
if (root && !terminal(root.status))
throw new Error('--max-steps cannot be applied to a queued or active Turn');
await authorizeExecution();
const started = await connection.request('turn.start', {
sessionId,
turnId: env.newId(),
content: { text: options.prompt },
maxSteps: options.maxSteps,
});
if (started.kind !== 'started') throw new Error('Turn did not start');
target = started.turn.turnId;
submission = 'started';
} else {
await authorizeExecution();
messageId = env.newId();
const originHostEpoch = connection.hostEpoch;
const submitted = await connection.request('turn.message.submit', {
originHostEpoch,
sessionId,
messageId,
content: { text: options.prompt },
placement: options.placement ?? 'next_turn',
});
if (submitted.disposition === 'blocked')
throw new Error('Message was blocked before execution');
if (submitted.disposition === 'turn_started') {
target = submitted.turnId;
submission = 'started';
} else if (submitted.disposition === 'steering') {
submission = 'steering';
admittedQueueRevision = submitted.queueRevision;
admittedQueueEpoch = originHostEpoch;
// Steering targets the Turn selected on attachment; it does not own a new Turn.
if (!target)
throw new Error(
'Steering was admitted but its observed Turn is unavailable; query Session status before continuing',
);
messageId = undefined;
} else if (submitted.disposition === 'inbox') {
// Inbox admission does not own a worker Turn or a queue revision.
// Return its receipt even with --wait; never reuse an older Turn's result.
assertActive();
const { queued: _queued, ...observed } = observer.status();
return { ...observed, submission: 'inbox' };
} else {
target = undefined;
submission = 'queued';
admittedQueueRevision = submitted.queueRevision;
admittedQueueEpoch = originHostEpoch;
}
}
}
assertActive();
if (options.status || (!executing && !decidingSandbox && !options.wait && !options.result)) {
const executionBoundary = await connection.request('session.execution_boundary.query', {
sessionId,
});
assertActive();
return {
...observer.status(),
executionBoundary,
...(configuration ? { configuration } : {}),
};
}
if (options.noWait) {
if (submission !== 'queued' && submission !== 'steering')
return { sessionId, status: 'running', ...(submission ? { submission } : {}) };
const observed = observer.status();
// Admission can precede its projection frame. Never report an older queue count as current.
const { queued, ...withoutQueueCount } = observed;
const hasAdmittedRevision =
admittedQueueRevision !== undefined &&
observer.snapshot.queue.hostEpoch === admittedQueueEpoch &&
observer.snapshot.queue.queueRevision >= admittedQueueRevision;
return { ...(hasAdmittedRevision ? observed : withoutQueueCount), submission };
}
if (!target && !messageId) target = await observer.latestTurn();
if (!target && !messageId) return { sessionId, status: 'idle' };
for (;;) {
const version = observer.version;
cancelled.signal.throwIfAborted();
observer.throwIfFailed();
if (messageId && !target) {
const execution = await connection.request('turn.message.execution.query', {
sessionId,
messageIds: [messageId],
});
const resolution = execution.resolutions[0];
if (resolution?.state === 'owned') target = resolution.turnId;
else if (resolution?.state === 'cancelled' || resolution?.state === 'not_admitted') {
throw new Error(`Submitted message is ${resolution.state}; it has no execution result`);
}
}
if (target) {
const questions = observer.questions(target);
if (questions.length) return observer.questionResult(questions);
const approvals = observer.approvals(target);
if (approvals.length) return observer.approvalResult(approvals);
const turn = await connection.request('turn.query', { sessionId, turnId: target });
if (terminal(turn.status)) {
const result = await observer.result(target);
if (result) return result;
if (options.result)
throw new Error('The terminal result is still being published; query it again');
await observer.changed(version);
continue;
}
const unsupported = observer.snapshot.interactions.pending.find(
(p) =>
p.turnId === target &&
p.request.kind !== 'question' &&
p.request.kind !== 'sandbox_boundary',
);
if (unsupported)
throw new Error(
`Pending ${unsupported.request.kind} interaction cannot be answered by maka run. The Host Turn is still waiting; use an interactive client on this Host or run maka run --session ${sessionId} --stop with the same Host selection to stop it explicitly.`,
);
if (options.result) return { sessionId, status: 'running' };
}
await observer.changed(version);
}
} finally {
removeSigint();
await observer.close();
}
}
function terminal(status: string): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled';
}
async function configureSessionModel(
connection: RuntimeHostConnection,
options: MakaRunOptions,
assertActive: () => void,
): Promise<SessionCatalogProjection> {
const selected =
options.connection !== undefined
? (await readRuntimeHostConnectionCatalog(connection)).connections.find(
(candidate) => candidate.slug === options.connection,
)
: undefined;
if (options.connection !== undefined && !selected)
throw new Error(`Runtime Host model connection is unavailable: ${options.connection}`);
return updateRuntimeHostSession(
connection,
options.sessionId!,
(current) => {
const changesModel = options.model !== undefined || options.connection !== undefined;
let patch: SessionConfigurationPatch =
options.thinking !== undefined ? { thinkingLevel: options.thinking } : {};
if (changesModel) {
if (current.executorId && !selected) {
patch = { ...patch, executorConfig: { model: options.model! } };
} else {
const connectionId = selected?.connectionId ?? current.llmConnectionId;
if (!connectionId) throw new Error('Model selection requires an explicit --connection');
const changesTarget =
connectionId !== current.llmConnectionId ||
(options.model ?? current.model) !== current.model;
patch = {
...patch,
...(changesTarget && options.thinking === undefined ? { thinkingLevel: null } : {}),
modelTarget: {
kind: 'explicit',
connectionId,
connectionSlug: selected?.slug ?? current.llmConnectionSlug,
model: options.model ?? current.model,
},
};
}
}
return connection.request('session.configuration.update', {
sessionId: options.sessionId!,
expectedRevision: current.revision,
patch,
});
},
{ operation: 'session.configuration.update', assertRequestAllowed: assertActive },
);
}
class SessionRunObserver {
readonly #connection: RuntimeHostConnection;
readonly #sessionId: string;
readonly #signal: AbortSignal;
#channel!: RuntimeHostSessionChannel;
#version = 0;
#failure: unknown;
#waiters = new Set<() => void>();
#answered = new Set<string>();
private constructor(connection: RuntimeHostConnection, sessionId: string, signal: AbortSignal) {
this.#connection = connection;
this.#sessionId = sessionId;
this.#signal = signal;
}
static async open(
connection: RuntimeHostConnection,
sessionId: string,
signal: AbortSignal,
): Promise<SessionRunObserver> {
const observer = new SessionRunObserver(connection, sessionId, signal);
const changed = () => observer.#notify();
const opened = await RuntimeHostSessionChannel.open({
connection,
sessionId,
signal,
now: Date.now,
onTurnStarted: (turn) => {
// Drain live events so a long-running observation cannot back up the channel.
void (async () => {
for await (const _event of turn.events) {
/* results are reconciled from durable transcript */
}
})().catch((error) => {
observer.#failure = error;
changed();
});
},
onRuntimeResourceChanged: changed,
onInteractionPending: changed,
onInteractionResolved: changed,
onTranscriptSettlement: changed,
onTranscriptReplaced: changed,
onGoalChanged: changed,
onSnapshotChanged: changed,
onRecovered: changed,
onFailed: (error) => {
observer.#failure = error;
changed();
},
});
observer.#channel = opened.channel;
opened.channel.activate();
return observer;
}
get snapshot(): SessionContinuitySnapshot {
return this.#channel.snapshot;
}
get version(): number {
return this.#version;
}
throwIfFailed(): void {
if (this.#failure) throw this.#failure;
}
ignoreAnswered(id: string): void {
this.#answered.add(id);
}
questions(turnId?: string): InteractionPendingSnapshot[] {
return this.snapshot.interactions.pending.filter(
(p) =>
p.request.kind === 'question' &&
!this.#answered.has(p.interactionId) &&
(turnId === undefined || p.turnId === turnId),
);
}
questionResult(pending: readonly InteractionPendingSnapshot[]): MakaRunSessionResult {
return {
sessionId: this.#sessionId,
status: 'needs_input',
questions: pending.flatMap((p) => (p.request.kind === 'question' ? p.request.questions : [])),
...(pending.length > 1 ? { pendingQuestionCount: pending.length } : {}),
};
}
approvals(turnId?: string): InteractionPendingSnapshot[] {
return this.snapshot.interactions.pending.filter(
(p) =>
p.request.kind === 'sandbox_boundary' &&
!this.#answered.has(p.interactionId) &&
(turnId === undefined || p.turnId === turnId),
);
}
approvalResult(pending: readonly InteractionPendingSnapshot[]): MakaRunSessionResult {
return {
sessionId: this.#sessionId,
status: 'needs_approval',
approvalRequests: pending.flatMap((p) =>
p.request.kind === 'sandbox_boundary' ? [p.request] : [],
),
};
}
status(): MakaRunSessionResult {
const pending = this.questions();
if (pending.length)
return {
...this.questionResult(pending),
queued: this.snapshot.queue.followup.length + this.snapshot.queue.steering.length,
};
const approvals = this.approvals();
if (approvals.length)
return {
...this.approvalResult(approvals),
queued: this.snapshot.queue.followup.length + this.snapshot.queue.steering.length,
};
const root = this.snapshot.rootTurn;
return {
sessionId: this.#sessionId,
status: !root
? 'idle'
: root.status === 'completed'
? 'completed'
: terminal(root.status)
? 'failed'
: 'running',
queued: this.snapshot.queue.followup.length + this.snapshot.queue.steering.length,
...(root && 'providerRetry' in root && root.providerRetry
? { providerRetry: root.providerRetry }
: {}),
};
}
async latestTurn(): Promise<string | undefined> {
const result = await this.#connection.request('session.turn_landmarks.query', {
sessionId: this.#sessionId,
maxLandmarks: 1,
turnId: null,
});
return result.landmarks.at(-1)?.turnId;
}
async result(turnId: string): Promise<MakaRunSessionResult | undefined> {
const classifier = new TurnOutcomeClassifier(turnId);
await this.#channel.replayTranscript(async (messages: readonly StoredMessage[]) => {
acceptStoredTurn(classifier, messages, turnId);
}, this.#signal);
const outcome = classifier.outcome('pending');
if (!outcome) return undefined;
if (outcome.status === 'completed') {
return {
sessionId: this.#sessionId,
status: 'completed',
finalOutput: outcome.finalOutput ?? '',
};
}
return {
sessionId: this.#sessionId,
status: 'failed',
error: outcome.failure?.message ?? outcome.failure?.class ?? 'Turn failed',
};
}
changed(version: number): Promise<void> {
this.#signal.throwIfAborted();
if (this.#version !== version) return Promise.resolve();
return new Promise((resolve, reject) => {
const done = () => {
this.#signal.removeEventListener('abort', abort);
this.#waiters.delete(done);
resolve();
};
const abort = () => {
this.#waiters.delete(done);
reject(this.#signal.reason);
};
this.#waiters.add(done);
this.#signal.addEventListener('abort', abort, { once: true });
});
}
#notify(): void {
this.#version++;
for (const wake of [...this.#waiters]) wake();
}
async close(): Promise<void> {
await this.#channel.close();
}
}