You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by "me2seeks (via GitHub)" <gi...@apache.org> on 2026/10/16 09:21:55 UTC
[PR] feat(cli): add session-based run interaction [maka]
me2seeks opened a new pull request, #6133:
URL: https://github.com/apache/maka/pull/6133
## Summary
`maka run` currently stops a Turn when it requests user input, so a supervising agent cannot answer and continue it. This change returns built-in AskUser questions with `needs_input` and exit code 0, keeps the Host Turn waiting, and lets another CLI invocation answer the original tool call.
- Add Session-based wait, status, result, answer, queue, steer, safe-resume, JSON output, and explicit nonblocking submission. Waiting follows the submitted message or the Turn selected on attachment; each explicit queued message keeps its own subsequent Turn.
- Keep status/result/answer attachment independent of model readiness and client MCP startup. Reuse Host subscriptions, message admission, Interaction answers, and durable results without changing Host or Runtime protocols.
- Preserve the existing blocking run path, legacy `--resume <id>` selector, Graph completion, limits, permissions, and cancellation. Check cancellation before and after admission so Ctrl+C during an in-flight read cannot submit new work afterward.
Scope is limited to `packages/cli`, including tests and English/Chinese usage documentation. Client-owned MCP continuation across CLI exits and Graph question handoff remain unsupported; the existing blocking MCP path stays available, and nonblocking submission refuses to detach MCP servers owned by the invocation. Local temporary npx execution retains the blocking path because it may own the Host lifetime.
## Verification
- CLI build and typecheck passed.
- 182 affected tests passed: argument/process contracts, run adapter, Session selection/driver, MCP preparation, CLI bootstrap, policy, and a real Host with separate CLI processes.
- The real Host regression verifies AskUser survives the first CLI exit, answering preserves the original Turn/Run, two queued messages receive distinct Turns, wait returns the second message's result, steer is delivered, and failure diagnostics survive result lookup.
- The added Ctrl+C regression failed before the review fix (a message was still admitted) and passes afterward with zero admissions.
- Biome on all changed TypeScript files, ASF header audit, and `git diff --check` passed. Full repository tests and native Windows/macOS runs were not performed; hosted CI remains pending.
Example interaction:
```sh
maka run "Inspect this project" --no-wait
maka run --session "$session" --wait --json
# needs_input, with questions and options
maka run --session "$session" --answer '["Yes",null]' --wait --json
maka run --session "$session" --result --json
```
## AI use
- [ ] No generative tool made a substantive contribution
- [x] Generative tooling made a substantive contribution
Tool(s) and scope: Codex assisted with design, source review, implementation, documentation, and tests. The commit includes a `Generated-by: Codex` trailer. This PR description was prepared with Codex.
## Checklist
- [x] Tests cover the change and fail without it
- [x] Lint, format, typecheck and the affected suites pass locally
Does this PR entail a change in behavior?
- [x] Yes — described under Summary above
- [ ] No
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(cli): add session-based run interaction [maka]
Posted by "Astro-Han (via GitHub)" <gi...@apache.org>.
Astro-Han commented on code in PR #6133:
URL: https://github.com/apache/maka/pull/6133#discussion_r4294997708
##########
packages/cli/src/run-session-command.ts:
##########
@@ -0,0 +1,507 @@
+/*
+ * 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 type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { updateRuntimeHostSession } from './runtime-host-session-update.js';
+import type {
+ InteractionPendingSnapshot,
+ 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 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;
+ 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.connection ||
+ options.model ||
+ options.thinking !== undefined ||
+ options.cwd ||
+ options.projectId ||
+ options.timeoutMs
+ ) {
+ throw new Error(
+ 'Session commands use the saved configuration; use the blocking legacy --resume <id> path for configuration, cwd, timeout or Graph options',
+ );
+ }
+ 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();
+ let target = observer.snapshot.rootTurn?.turnId;
+ let messageId: string | undefined;
+ let submission: MakaRunSessionResult['submission'];
+ let admittedQueueRevision: number | undefined;
+ let admittedQueueEpoch: string | undefined;
+ let fullAccess = false;
+ if (executing) {
+ 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();
Review Comment:
P3: `--approve` grants whichever single sandbox request is pending when this invocation attaches. `approvalRequests` omits the `interactionId`, so a caller cannot tie the decision to the request it reviewed. If that request was resolved elsewhere and a broader one replaced it, the broader one is approved and persists for the Session. Consider exposing an opaque request token and requiring it (or checking it) here, and echoing the decided expansion in the output.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(cli): add session-based run interaction [maka]
Posted by "me2seeks (via GitHub)" <gi...@apache.org>.
me2seeks commented on code in PR #6133:
URL: https://github.com/apache/maka/pull/6133#discussion_r4309915514
##########
packages/cli/src/run-session-command.ts:
##########
@@ -0,0 +1,507 @@
+/*
+ * 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 type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { updateRuntimeHostSession } from './runtime-host-session-update.js';
+import type {
+ InteractionPendingSnapshot,
+ 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 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;
+ 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.connection ||
+ options.model ||
+ options.thinking !== undefined ||
+ options.cwd ||
+ options.projectId ||
+ options.timeoutMs
+ ) {
+ throw new Error(
+ 'Session commands use the saved configuration; use the blocking legacy --resume <id> path for configuration, cwd, timeout or Graph options',
+ );
+ }
+ 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();
+ let target = observer.snapshot.rootTurn?.turnId;
+ let messageId: string | undefined;
+ let submission: MakaRunSessionResult['submission'];
+ let admittedQueueRevision: number | undefined;
+ let admittedQueueEpoch: string | undefined;
+ let fullAccess = false;
+ if (executing) {
+ 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 {
+ 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 };
+ }
+ 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';
+}
+
+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' && outcome.sandboxBoundary === 'none') {
Review Comment:
**Codex-assisted author response (AI-generated).**
Confirmed and fixed in 34259eb467. Session result replay can reconcile one failed call with a successful durable call of the same tool and deeply equal full arguments in a different provider step. Approval alone never clears a failure; same-step siblings, different tools/arguments, missing evidence and other unresolved failures remain failures. Blocking run retains its denial policy. The real-Host test now emits failed Fetch → sandbox request → approval → successful Fetch retry, and checks both the deciding command and a later `--result`. Denial remains failed. Replacing only the classifier with the previous version fails the real-Host recovery and focused positive regression. CLI build and 198 affected tests pass.
##########
packages/cli/src/runtime-host-run-command.ts:
##########
@@ -300,8 +339,12 @@ class RuntimeHostRunRuntime implements MakaRunRuntime {
this.#graphEnabled = graphEnabled;
this.#sessionCwdOverride = sessionCwdOverride;
this.#maxSteps = maxSteps;
- this.#interactions = new NonInteractiveInteractionController(driver, (pending) =>
- this.#stopForInteraction(pending),
+ this.#noWait = noWait;
+ this.#allowDetach = allowDetach;
+ this.#interactions = new NonInteractiveInteractionController(
+ driver,
+ (pending) => this.#stopForInteraction(pending),
+ () => this.#allowDetach && !this.#graphEnabled && !this.#mcp?.ownsServers,
Review Comment:
**Codex-assisted author response (AI-generated).**
Confirmed and fixed in 34259eb467. The blocking interaction controller now leaves interactions untouched during detached admission, including events delivered before `preparePrompt` returns. A deterministic admission barrier reproduces the previous automatic denial and verifies no decision/stop for sandbox, question, form and permission requests. Ordinary blocking and Graph denial behavior, plus MCP and temporary-install detach guards, are retained. CLI build and 198 affected tests pass.
##########
packages/cli/src/run-command-core.ts:
##########
@@ -433,21 +586,47 @@ export async function runMakaTextCliCore(
deps.writeStderr(`maka run: timed out after ${parsed.options.timeoutMs}ms\n`);
return 1;
}
+ if (question) {
+ writeSessionResult(question.result(), deps, true);
+ return 0;
+ }
if (streamFailed) return 1;
if (boundaryFailure) return 1;
if (!outcome) {
deps.writeStderr('maka run: runtime produced no outcome\n');
return 1;
}
+ if (outcome.status === 'running') {
+ writeSessionResult(
+ { sessionId: session.id, status: 'running', submission: 'started' },
+ deps,
+ true,
+ );
+ return 0;
+ }
if (outcome.status !== 'completed' || outcome.finalOutput === undefined) {
const detail = outcome.failure?.message ?? outcome.failure?.class ?? 'runtime failure';
deps.writeStderr(`maka run: ${detail}\n`);
return 1;
}
- deps.writeStdout(withTrailingNewline(outcome.finalOutput));
+ writeSessionResult(
+ { sessionId: session.id, status: 'completed', finalOutput: outcome.finalOutput },
+ deps,
+ parsed.options.json === true,
+ );
return 0;
}
+export function writeSessionResult(
+ result: MakaRunSessionResult,
+ deps: Pick<MakaRunDeps, 'writeStdout' | 'writeStderr'>,
+ json: boolean,
+): void {
+ if (json || result.status !== 'completed') deps.writeStdout(`${JSON.stringify(result)}\n`);
Review Comment:
**Codex-assisted author response (AI-generated).**
Confirmed and fixed in 34259eb467. Plain-text rendering now requires an actual `finalOutput`, including an explicitly empty answer. Completed responses without an answer retain their structured status/control receipt. The regression covers explicit status, implicit Session status and stop on a completed Session without `--json`; it failed on the previous renderer. CLI build and 198 affected tests pass.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(cli): add session-based run interaction [maka]
Posted by "me2seeks (via GitHub)" <gi...@apache.org>.
me2seeks commented on code in PR #6133:
URL: https://github.com/apache/maka/pull/6133#discussion_r4309916199
##########
packages/cli/src/run-command-core.ts:
##########
@@ -433,21 +586,47 @@ export async function runMakaTextCliCore(
deps.writeStderr(`maka run: timed out after ${parsed.options.timeoutMs}ms\n`);
return 1;
}
+ if (question) {
+ writeSessionResult(question.result(), deps, true);
+ return 0;
+ }
if (streamFailed) return 1;
if (boundaryFailure) return 1;
if (!outcome) {
deps.writeStderr('maka run: runtime produced no outcome\n');
return 1;
}
+ if (outcome.status === 'running') {
+ writeSessionResult(
+ { sessionId: session.id, status: 'running', submission: 'started' },
+ deps,
+ true,
+ );
+ return 0;
+ }
if (outcome.status !== 'completed' || outcome.finalOutput === undefined) {
const detail = outcome.failure?.message ?? outcome.failure?.class ?? 'runtime failure';
deps.writeStderr(`maka run: ${detail}\n`);
return 1;
}
- deps.writeStdout(withTrailingNewline(outcome.finalOutput));
+ writeSessionResult(
Review Comment:
**Codex-assisted author response (AI-generated).**
Confirmed and fixed in 34259eb467. An ordinary failed terminal outcome now honors `--json`, returning `sessionId`, `status: failed` and the error text while retaining exit 1 and a single stderr diagnostic. Plain-mode failure output is preserved. The adapter/public-command regression failed with empty stdout on the previous implementation. CLI build and 198 affected tests pass.
##########
packages/cli/src/run-session-command.ts:
##########
@@ -0,0 +1,393 @@
+/*
+ * 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 { StoredMessage } from '@maka/core/session';
+import type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { updateRuntimeHostSession } from './runtime-host-session-update.js';
+import type {
+ InteractionPendingSnapshot,
+ 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 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;
+ if (executing && !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.connection ||
+ options.model ||
+ options.thinking !== undefined ||
+ options.cwd ||
+ options.projectId ||
+ options.timeoutMs
+ ) {
+ throw new Error(
+ 'Session commands use the saved configuration; use the blocking legacy --resume <id> path for configuration, cwd, timeout or Graph options',
+ );
+ }
+ 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();
+ let target = observer.snapshot.rootTurn?.turnId;
+ let messageId: string | undefined;
+ if (executing) {
+ 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');
+ if (options.yolo) {
+ await updateRuntimeHostSession(
+ connection,
+ sessionId,
+ (current) =>
+ connection.request('session.configuration.update', {
+ sessionId,
+ expectedRevision: current.revision,
+ patch: { permissionMode: 'bypass' },
+ }),
+ { operation: 'session.configuration.update', assertRequestAllowed: assertActive },
+ );
+ } else if (boundary.kind === 'bypass') {
+ throw new Error(
+ `Session ${sessionId} has full access. Repeat this command with --yolo to submit a prompt, answer AskUser, or resume execution. Read-only --status, --wait and --result do not require --yolo; no action was submitted.`,
+ );
+ }
+ }
+ assertActive();
+ 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]!;
+ 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}`);
+ 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');
+ 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;
+ } else {
+ messageId = env.newId();
+ const submitted = await connection.request('turn.message.submit', {
+ originHostEpoch: connection.hostEpoch,
+ 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;
+ else if (submitted.disposition === 'steering') {
+ // 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 target = undefined;
+ }
+ }
+ assertActive();
+ if (options.status || (!executing && !options.wait && !options.result))
+ return observer.status();
+ if (options.noWait) return { sessionId, status: 'running' };
+ 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 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',
+ );
+ if (unsupported)
Review Comment:
**Codex-assisted author response (AI-generated).**
Addressed in 99e40de1 and 5ff2dec5: explicit Session `--stop`, sandbox `--approve`/`--deny`, and errors naming the actual unsupported interaction and recovery command. The real-Host regression covers detached sandbox handoff, explicit stop, and successful subsequent execution; sandbox decisions preserve the original Turn/Run. Revalidated in the current 198-test affected run. The newer detached-admission race is separately fixed in 34259eb467.
##########
packages/cli/src/run-session-command.ts:
##########
@@ -0,0 +1,393 @@
+/*
+ * 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 { StoredMessage } from '@maka/core/session';
+import type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { updateRuntimeHostSession } from './runtime-host-session-update.js';
+import type {
+ InteractionPendingSnapshot,
+ 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 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;
+ if (executing && !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.connection ||
+ options.model ||
+ options.thinking !== undefined ||
+ options.cwd ||
+ options.projectId ||
+ options.timeoutMs
+ ) {
+ throw new Error(
+ 'Session commands use the saved configuration; use the blocking legacy --resume <id> path for configuration, cwd, timeout or Graph options',
+ );
+ }
+ 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();
+ let target = observer.snapshot.rootTurn?.turnId;
+ let messageId: string | undefined;
+ if (executing) {
+ 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');
+ if (options.yolo) {
Review Comment:
**Codex-assisted author response (AI-generated).**
The reported answer/resume preflight issue was fixed in 99e40de1. Pending count, answer shape, resume plan and step-cap checks now precede explicit elevation, with regression assertions that rejected actions leave the boundary unchanged. Revalidated in the current affected suite. This does not claim atomicity between explicit elevation and a later Host admission rejection; that narrower limitation noted in the incremental review remains.
##########
packages/cli/src/run-command-core.ts:
##########
@@ -239,6 +342,23 @@ export async function runMakaTextCliCore(
return 2;
}
+ if (parsed.options.sessionId) {
+ try {
+ if (!deps.runSession)
+ throw new Error('Session operations are unavailable in this CLI runtime');
+ const options = { ...parsed.options };
+ // Queries and answers must not block reading an unrelated stdin stream.
+ if (options.prompt !== undefined || options.stdinPrompt)
+ options.prompt = await resolveRunPrompt(options, deps);
+ const result = await deps.runSession(options, deps);
+ writeSessionResult(result, deps, options.json === true);
+ return result.status === 'failed' ? 1 : 0;
Review Comment:
**Codex-assisted author response (AI-generated).**
Fixed in 99e40de1: explicit/implicit status and stop report successful query/control execution with exit 0 even when the child Turn failed. Wait/result/decision commands retain failure exits. Argument/public-command regression tests cover these distinctions. 34259eb467 also fixes blank completed status/control output.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(cli): add session-based run interaction [maka]
Posted by "chinawch007 (via GitHub)" <gi...@apache.org>.
chinawch007 commented on code in PR #6133:
URL: https://github.com/apache/maka/pull/6133#discussion_r4303959656
##########
packages/cli/src/runtime-host-run-command.ts:
##########
@@ -300,8 +339,12 @@ class RuntimeHostRunRuntime implements MakaRunRuntime {
this.#graphEnabled = graphEnabled;
this.#sessionCwdOverride = sessionCwdOverride;
this.#maxSteps = maxSteps;
- this.#interactions = new NonInteractiveInteractionController(driver, (pending) =>
- this.#stopForInteraction(pending),
+ this.#noWait = noWait;
+ this.#allowDetach = allowDetach;
+ this.#interactions = new NonInteractiveInteractionController(
+ driver,
+ (pending) => this.#stopForInteraction(pending),
+ () => this.#allowDetach && !this.#graphEnabled && !this.#mcp?.ownsServers,
Review Comment:
**AI review (Codex) — [P2] Disable the blocking interaction policy during detached admission**
`--no-wait` still installs `NonInteractiveInteractionController` before admission. If a sandbox request arrives before the launcher finishes `preparePrompt`, the controller automatically sends `deny`; the no-wait branch then returns a successful `running` receipt. I reproduced this with the real Host/driver by delaying delivery of the successful `turn.start` response until its pending interaction arrived: the public CLI returned exit 0 and `submission: started`, but `interaction.answer(deny)` had already been sent and the later result was `Fake sandbox boundary decision: deny`. Thus an early request is denied whereas the same request arriving after launcher exit remains available to `--approve`. Detached admission should leave these interactions for Session controls while preserving ordinary blocking-run denial behavior.
##########
packages/cli/src/run-session-command.ts:
##########
@@ -0,0 +1,507 @@
+/*
+ * 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 type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { updateRuntimeHostSession } from './runtime-host-session-update.js';
+import type {
+ InteractionPendingSnapshot,
+ 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 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;
+ 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.connection ||
+ options.model ||
+ options.thinking !== undefined ||
+ options.cwd ||
+ options.projectId ||
+ options.timeoutMs
+ ) {
+ throw new Error(
+ 'Session commands use the saved configuration; use the blocking legacy --resume <id> path for configuration, cwd, timeout or Graph options',
+ );
+ }
+ 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();
+ let target = observer.snapshot.rootTurn?.turnId;
+ let messageId: string | undefined;
+ let submission: MakaRunSessionResult['submission'];
+ let admittedQueueRevision: number | undefined;
+ let admittedQueueEpoch: string | undefined;
+ let fullAccess = false;
+ if (executing) {
+ 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 {
+ 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 };
+ }
+ 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';
+}
+
+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' && outcome.sandboxBoundary === 'none') {
Review Comment:
**AI review (Codex) — [P2] Recognize successful recovery after an approved sandbox expansion**
The new Session path reuses `TurnOutcomeClassifier`, whose sandbox-failure set never clears because it assumes that `maka run` always denies expansions. That assumption no longer holds for `--approve`. The supported workflow is a tool returning `sandbox_boundary_required`, an approved expansion, and a successful retry. With a real Host and a FakeBackend emitting that sequence (same Fetch arguments before and after approval), both `--approve --wait` and subsequent `--result` report `failed: Turn encountered an unresolved sandbox boundary` and discard the final answer, although the Host Turn completes. The added test requests expansion without a preceding tool failure, so it misses this path. Reconcile authorized recovery in Session result classification while preserving the blocking path's denial protection and unrelated unresolved failures.
##########
packages/cli/src/run-command-core.ts:
##########
@@ -433,21 +586,47 @@ export async function runMakaTextCliCore(
deps.writeStderr(`maka run: timed out after ${parsed.options.timeoutMs}ms\n`);
return 1;
}
+ if (question) {
+ writeSessionResult(question.result(), deps, true);
+ return 0;
+ }
if (streamFailed) return 1;
if (boundaryFailure) return 1;
if (!outcome) {
deps.writeStderr('maka run: runtime produced no outcome\n');
return 1;
}
+ if (outcome.status === 'running') {
+ writeSessionResult(
+ { sessionId: session.id, status: 'running', submission: 'started' },
+ deps,
+ true,
+ );
+ return 0;
+ }
if (outcome.status !== 'completed' || outcome.finalOutput === undefined) {
const detail = outcome.failure?.message ?? outcome.failure?.class ?? 'runtime failure';
deps.writeStderr(`maka run: ${detail}\n`);
return 1;
}
- deps.writeStdout(withTrailingNewline(outcome.finalOutput));
+ writeSessionResult(
+ { sessionId: session.id, status: 'completed', finalOutput: outcome.finalOutput },
+ deps,
+ parsed.options.json === true,
+ );
return 0;
}
+export function writeSessionResult(
+ result: MakaRunSessionResult,
+ deps: Pick<MakaRunDeps, 'writeStdout' | 'writeStderr'>,
+ json: boolean,
+): void {
+ if (json || result.status !== 'completed') deps.writeStdout(`${JSON.stringify(result)}\n`);
Review Comment:
**AI review (Codex) — [P2] Keep completed status/control responses visible without `--json`**
Not every `completed` response contains a final answer. `SessionRunObserver.status()` returns status, queue count and execution boundary without `finalOutput`, so this branch converts the documented `maka run --session ID --status` command into a single blank line once the Session completes. A real Host plus separate CLI processes reproduces exit 0 / stdout `"\n"` for explicit status, implicit `--session ID`, and `--stop` on an already completed Turn; adding `--json` exposes the missing fields. This also hides the execution boundary that status is documented to report. Select plain-text rendering for actual final-answer responses, and retain a visible status/control receipt when there is no `finalOutput`.
##########
packages/cli/src/run-command-core.ts:
##########
@@ -433,21 +586,47 @@ export async function runMakaTextCliCore(
deps.writeStderr(`maka run: timed out after ${parsed.options.timeoutMs}ms\n`);
return 1;
}
+ if (question) {
+ writeSessionResult(question.result(), deps, true);
+ return 0;
+ }
if (streamFailed) return 1;
if (boundaryFailure) return 1;
if (!outcome) {
deps.writeStderr('maka run: runtime produced no outcome\n');
return 1;
}
+ if (outcome.status === 'running') {
+ writeSessionResult(
+ { sessionId: session.id, status: 'running', submission: 'started' },
+ deps,
+ true,
+ );
+ return 0;
+ }
if (outcome.status !== 'completed' || outcome.finalOutput === undefined) {
const detail = outcome.failure?.message ?? outcome.failure?.class ?? 'runtime failure';
deps.writeStderr(`maka run: ${detail}\n`);
return 1;
}
- deps.writeStdout(withTrailingNewline(outcome.finalOutput));
+ writeSessionResult(
Review Comment:
**AI review (Codex) — [P2] Emit the requested JSON result for terminal failures**
The new `--json` renderer is reached only for successful outcomes: the `outcome.status !== 'completed'` branch above returns after writing stderr. For an admitted ordinary run that ends with a provider error, `maka run ... --json` therefore emits no JSON at all. With the real Host/adapter, a successful control run produced completed JSON, while a deterministic auth failure produced exit 1, empty stdout and only `maka run: Deterministic auth failure` on stderr; a subsequent Session result query confirmed the durable failed Turn. This breaks the documented structured-status workflow, and a newly created Session also loses its returned ID for inspection/recovery. Serialize terminal failures with the Session ID and error when JSON is requested, while retaining the failure exit code and diagnostic.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(cli): add session-based run interaction [maka]
Posted by "me2seeks (via GitHub)" <gi...@apache.org>.
me2seeks commented on code in PR #6133:
URL: https://github.com/apache/maka/pull/6133#discussion_r4309916956
##########
packages/cli/src/__tests__/run-session-host.test.ts:
##########
@@ -0,0 +1,280 @@
+/*
+ * 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 assert from 'node:assert/strict';
+import { spawn, type ChildProcess } from 'node:child_process';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { test } from 'node:test';
+import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
+import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores';
+import {
+ FakeBackend,
+ FAKE_ASK_USER_QUESTION_PROMPT,
+ FAKE_WAIT_FOR_STEERING_PROMPT,
+ FAKE_ERROR_PROMPT_PREFIX,
+} from '@maka/runtime/test-only/fake-backend';
+import { startExecutionRuntimeHostService } from '@maka/runtime-host/server';
+import { runMakaSessionCommand } from '../run-session-command.js';
+import { RunSessionInterrupted } from '../run-session-result.js';
+import { randomUUID } from 'node:crypto';
+import { connectRuntimeHost } from '@maka/runtime-host/client';
+import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol';
+
+const children = new Set<ChildProcess>();
+
+const fixturePath = fileURLToPath(new URL('./run-session-process-fixture.js', import.meta.url));
+
+test('real Host keeps AskUser alive across CLI processes and returns the original Turn result', {
+ timeout: 30_000,
Review Comment:
**Codex-assisted author response (AI-generated).**
Fixed in 99e40de1 by raising the real-Host test budget to 120 seconds. The current combined affected run passed all 198 tests, including this scenario with separate CLI processes; an isolated counterfactual recovery run also completed within the budget.
##########
packages/cli/src/run-command-core.ts:
##########
@@ -433,21 +556,43 @@ export async function runMakaTextCliCore(
deps.writeStderr(`maka run: timed out after ${parsed.options.timeoutMs}ms\n`);
return 1;
}
+ if (question) {
Review Comment:
**Codex-assisted author response (AI-generated).**
This is an intentional handoff contract: ordinary AskUser returns `needs_input`, exits 0 and leaves the Host Turn waiting so the coordinating agent can answer in a later invocation. This is a documented behavior change; callers must inspect status instead of interpreting exit 0 as a final answer. Keeping this separate from correctness fixes rather than changing the agreed interaction contract in response to the review. Leaving the design discussion open.
##########
packages/cli/src/run-session-command.ts:
##########
@@ -0,0 +1,507 @@
+/*
+ * 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 type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { updateRuntimeHostSession } from './runtime-host-session-update.js';
+import type {
+ InteractionPendingSnapshot,
+ 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 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;
+ 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.connection ||
+ options.model ||
+ options.thinking !== undefined ||
+ options.cwd ||
+ options.projectId ||
+ options.timeoutMs
+ ) {
+ throw new Error(
+ 'Session commands use the saved configuration; use the blocking legacy --resume <id> path for configuration, cwd, timeout or Graph options',
+ );
+ }
+ 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();
+ let target = observer.snapshot.rootTurn?.turnId;
+ let messageId: string | undefined;
+ let submission: MakaRunSessionResult['submission'];
+ let admittedQueueRevision: number | undefined;
+ let admittedQueueEpoch: string | undefined;
+ let fullAccess = false;
+ if (executing) {
+ 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();
Review Comment:
**Codex-assisted author response (AI-generated).**
The cross-invocation replacement risk is real. Current safeguards bind the single request observed when this command attaches, reject ambiguity and send the answer once without an automatic retry; these do not prove it is the request a caller inspected in an earlier invocation. Documentation explicitly states that limitation. A complete binding needs an opaque approval token or equivalent expected-request contract, which is a separate interface decision from the four reproduced P2 fixes. No claim that the risk is eliminated; leaving this thread open.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(cli): add session-based run interaction [maka]
Posted by "me2seeks (via GitHub)" <gi...@apache.org>.
me2seeks merged PR #6133:
URL: https://github.com/apache/maka/pull/6133
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(cli): add session-based run interaction [maka]
Posted by "Astro-Han (via GitHub)" <gi...@apache.org>.
Astro-Han commented on code in PR #6133:
URL: https://github.com/apache/maka/pull/6133#discussion_r4294081876
##########
packages/cli/src/run-session-command.ts:
##########
@@ -0,0 +1,393 @@
+/*
+ * 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 { StoredMessage } from '@maka/core/session';
+import type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { updateRuntimeHostSession } from './runtime-host-session-update.js';
+import type {
+ InteractionPendingSnapshot,
+ 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 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;
+ if (executing && !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.connection ||
+ options.model ||
+ options.thinking !== undefined ||
+ options.cwd ||
+ options.projectId ||
+ options.timeoutMs
+ ) {
+ throw new Error(
+ 'Session commands use the saved configuration; use the blocking legacy --resume <id> path for configuration, cwd, timeout or Graph options',
+ );
+ }
+ 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();
+ let target = observer.snapshot.rootTurn?.turnId;
+ let messageId: string | undefined;
+ if (executing) {
+ 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');
+ if (options.yolo) {
+ await updateRuntimeHostSession(
+ connection,
+ sessionId,
+ (current) =>
+ connection.request('session.configuration.update', {
+ sessionId,
+ expectedRevision: current.revision,
+ patch: { permissionMode: 'bypass' },
+ }),
+ { operation: 'session.configuration.update', assertRequestAllowed: assertActive },
+ );
+ } else if (boundary.kind === 'bypass') {
+ throw new Error(
+ `Session ${sessionId} has full access. Repeat this command with --yolo to submit a prompt, answer AskUser, or resume execution. Read-only --status, --wait and --result do not require --yolo; no action was submitted.`,
+ );
+ }
+ }
+ assertActive();
+ 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]!;
+ 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}`);
+ 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');
+ 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;
+ } else {
+ messageId = env.newId();
+ const submitted = await connection.request('turn.message.submit', {
+ originHostEpoch: connection.hostEpoch,
+ 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;
+ else if (submitted.disposition === 'steering') {
+ // 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 target = undefined;
+ }
+ }
+ assertActive();
+ if (options.status || (!executing && !options.wait && !options.result))
+ return observer.status();
+ if (options.noWait) return { sessionId, status: 'running' };
+ 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 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',
+ );
+ if (unsupported)
Review Comment:
P2: A pending permission/sandbox_boundary/form interaction makes this exit 1, but the Host Turn stays blocked indefinitely. Nothing on the Session path (or `--no-wait`) denies the sandbox boundary or stops the Turn the way the blocking path's NonInteractiveInteractionController does, and the CLI has no way to stop it. The message also names MCP forms whatever the actual kind is.
##########
packages/cli/src/run-session-command.ts:
##########
@@ -0,0 +1,393 @@
+/*
+ * 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 { StoredMessage } from '@maka/core/session';
+import type { RuntimeHostConnection } from '@maka/runtime-host/client';
+import { updateRuntimeHostSession } from './runtime-host-session-update.js';
+import type {
+ InteractionPendingSnapshot,
+ 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 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;
+ if (executing && !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.connection ||
+ options.model ||
+ options.thinking !== undefined ||
+ options.cwd ||
+ options.projectId ||
+ options.timeoutMs
+ ) {
+ throw new Error(
+ 'Session commands use the saved configuration; use the blocking legacy --resume <id> path for configuration, cwd, timeout or Graph options',
+ );
+ }
+ 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();
+ let target = observer.snapshot.rootTurn?.turnId;
+ let messageId: string | undefined;
+ if (executing) {
+ 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');
+ if (options.yolo) {
Review Comment:
P3: The Session is escalated to bypass before the `--answer`/`--resume` preconditions are checked. If the command is then rejected (for example, 0 or more than 1 pending AskUser, or the resume plan is not ready), it still leaves the Session at full access.
##########
packages/cli/src/run-command-core.ts:
##########
@@ -239,6 +342,23 @@ export async function runMakaTextCliCore(
return 2;
}
+ if (parsed.options.sessionId) {
+ try {
+ if (!deps.runSession)
+ throw new Error('Session operations are unavailable in this CLI runtime');
+ const options = { ...parsed.options };
+ // Queries and answers must not block reading an unrelated stdin stream.
+ if (options.prompt !== undefined || options.stdinPrompt)
+ options.prompt = await resolveRunPrompt(options, deps);
+ const result = await deps.runSession(options, deps);
+ writeSessionResult(result, deps, options.json === true);
+ return result.status === 'failed' ? 1 : 0;
Review Comment:
P3: `--status` exits 1 when the latest Turn failed or was cancelled, and prints no stderr message. A read-only query probably should not report failure through its exit code.
##########
packages/cli/src/__tests__/run-session-host.test.ts:
##########
@@ -0,0 +1,280 @@
+/*
+ * 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 assert from 'node:assert/strict';
+import { spawn, type ChildProcess } from 'node:child_process';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { test } from 'node:test';
+import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
+import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores';
+import {
+ FakeBackend,
+ FAKE_ASK_USER_QUESTION_PROMPT,
+ FAKE_WAIT_FOR_STEERING_PROMPT,
+ FAKE_ERROR_PROMPT_PREFIX,
+} from '@maka/runtime/test-only/fake-backend';
+import { startExecutionRuntimeHostService } from '@maka/runtime-host/server';
+import { runMakaSessionCommand } from '../run-session-command.js';
+import { RunSessionInterrupted } from '../run-session-result.js';
+import { randomUUID } from 'node:crypto';
+import { connectRuntimeHost } from '@maka/runtime-host/client';
+import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol';
+
+const children = new Set<ChildProcess>();
+
+const fixturePath = fileURLToPath(new URL('./run-session-process-fixture.js', import.meta.url));
+
+test('real Host keeps AskUser alive across CLI processes and returns the original Turn result', {
+ timeout: 30_000,
Review Comment:
P3: The 30s budget is tight. This test took 15-28s alone on a loaded 2-core machine and was cancelled once when run alongside the other CLI suites.
##########
packages/cli/src/run-command-core.ts:
##########
@@ -433,21 +556,43 @@ export async function runMakaTextCliCore(
deps.writeStderr(`maka run: timed out after ${parsed.options.timeoutMs}ms\n`);
return 1;
}
+ if (question) {
Review Comment:
P3: The default `maka run` now exits 0 with JSON on stdout when the model asks a question, and leaves the Turn waiting in the Host. Before this change it exited 1. A distinct exit code, or opting in via `--json`/`--wait`, would keep existing scripts from mistaking the JSON for the answer.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org