diff --git a/docs/architecture/runtime-resume-architecture.md b/docs/architecture/runtime-resume-architecture.md index aa971933c9..3d5754d026 100644 --- a/docs/architecture/runtime-resume-architecture.md +++ b/docs/architecture/runtime-resume-architecture.md @@ -491,6 +491,29 @@ sequenceDiagram CLI/TUI `/resume` uses the same `SessionManager` plan/execute seam. Desktop startup auto-resume also reuses it. +### Current parked-reason boundary + +Runtime Host projects Runtime planner rejection reasons into the closed +`TurnResumeParkReason` wire union. A CLI must not infer the Host's internal +state again. The current Host preserves three previously conflated causes: + +- `resume_feature_disabled`: the feature flag is off; +- `continuation_authority_unavailable`: the Host cannot obtain continuation authority; +- `safety_observation_unavailable`: the Host cannot obtain authoritative safety observations. + +The current wire contract no longer contains `continuation_unavailable`. The +`/resume` driver carries the exact reason in `SafeBoundaryResumeParkedError`. +The TUI renders only expected user states as informational notices: +`resume_feature_disabled`, `resume_candidate_missing`, and `session_busy`. +Authority, safety, and other recovery failures remain red errors with the raw +reason preserved for diagnosis. + +Because this changes a closed protocol union, Runtime Host compatibility epoch +57 rejects mixed old/new Client-Host pairs during handshake instead of letting +a Client misclassify a recovery failure as a disabled feature. This change only +corrects Host projection and CLI presentation; it does not move ownership of +the planner, durable continuation claim, or feature flag. + ## Why continuation does not duplicate the user message A normal Run creates an initial user RuntimeEvent. A continuation already has a validated source history, so it: diff --git a/docs/architecture/runtime-resume-architecture.zh-CN.md b/docs/architecture/runtime-resume-architecture.zh-CN.md index 260d85a3b0..4e8d97f03e 100644 --- a/docs/architecture/runtime-resume-architecture.zh-CN.md +++ b/docs/architecture/runtime-resume-architecture.zh-CN.md @@ -497,7 +497,25 @@ sequenceDiagram end ``` -CLI/TUI 的 `/resume` 走同一个 `SessionManager` plan/execute seam,只是把 park 作为命令错误展示。Desktop startup auto-resume 也复用同一 planner 和 execution path,不维护第三套恢复逻辑。 +CLI/TUI 的 `/resume` 走同一个 `SessionManager` plan/execute seam。Desktop startup auto-resume 也复用同一 planner 和 execution path,不维护第三套恢复逻辑。 + +### 当前的 parked 原因边界 + +Runtime Host 负责把 Runtime planner 的 rejection reasons 投影成封闭的 +`TurnResumeParkReason` wire union;CLI 不能重新推断 Host 内部状态。当前 Host 会保留三种容易混淆的原因: + +- `resume_feature_disabled`:feature flag 未开启; +- `continuation_authority_unavailable`:Host 无法取得 continuation authority; +- `safety_observation_unavailable`:Host 无法取得安全观测事实。 + +旧的 `continuation_unavailable` 不再出现在当前 wire contract 中。`/resume` driver 通过 +`SafeBoundaryResumeParkedError` 原样携带原因,TUI 只把预期的用户状态显示为普通提示: +`resume_feature_disabled`、`resume_candidate_missing` 和 `session_busy`。authority、safety +以及其他恢复失败仍显示为红色错误,并保留原始 reason 供诊断。 + +这是不兼容的封闭协议变更,因此 Runtime Host compatibility epoch 推进到 57;旧 Client/Host +组合会在握手阶段被拒绝,而不是把新的 failure reason 误判成“功能未开启”。这次变更只修正 +Host 投影和 CLI 展示,不改变 planner、durable continuation claim 或 feature flag 的 owner。 ## 为什么 Continuation 不复制原用户消息 diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index a3b5239894..2dcc36814a 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -41,7 +41,7 @@ import type { } from '@maka/runtime-host/protocol'; import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { type ContextDiagnostics } from '@maka/runtime/context-diagnostics'; -import type { GoalProjection } from '@maka/runtime-host/protocol'; +import type { GoalProjection, TurnResumeParkReason } from '@maka/runtime-host/protocol'; import type { MakaPreparePromptOptions, MakaPreparedSessionTurn, @@ -57,6 +57,7 @@ import type { SessionResumeAvailability, } from '../session-driver.js'; import { skillInvocationBlockedMessage } from '../session-driver.js'; +import { SafeBoundaryResumeParkedError } from '../runtime-host-session-driver.js'; import { listApiKeyOnboardableProviders } from '../onboarding-catalog.js'; import type { MakaOnboardingSurface, @@ -5467,6 +5468,144 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('/resume parked by the host is informational, not a red error', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + driver.parkedResumeReason = 'resume_feature_disabled'; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + // Attach a session first so the driver actually has one to resume. + terminal.input('/session'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session')); + terminal.input('\r'); + await waitFor(() => driver.sessionIds.length === 1); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Safe-boundary resume is not enabled on this runtime', + ), + ); + assert.equal(driver.resumeCalls, 1); + + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + + test('/resume with no interrupted run explains there is nothing to resume', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + driver.parkedResumeReason = 'resume_candidate_missing'; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/session'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session')); + terminal.input('\r'); + await waitFor(() => driver.sessionIds.length === 1); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Nothing to resume: no interrupted run exists in this session.', + ), + ); + + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + + test('/resume keeps genuine parked recovery failures red', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + driver.parkedResumeReason = 'safety_check_failed'; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/session'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session')); + terminal.input('\r'); + await waitFor(() => driver.sessionIds.length === 1); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + 'Error: Safe-boundary resume parked: safety_check_failed', + ), + ); + + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + + for (const reason of [ + 'continuation_authority_unavailable', + 'safety_observation_unavailable', + ] as const) { + test(`/resume keeps ${reason} red`, async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + driver.parkedResumeReason = reason; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/session'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session')); + terminal.input('\r'); + await waitFor(() => driver.sessionIds.length === 1); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes( + `Error: Safe-boundary resume parked: ${reason}`, + ), + ); + + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + } test('/model refuses instead of opening the picker behind the running turn', async () => { const terminal = new FakeTerminal(); const driver = new SteeringTurnDriver(); @@ -7356,6 +7495,8 @@ class SlashCommandDriver extends FakeSessionDriver { readonly moves: string[] = []; startNewSessionCalls = 0; resumeCalls = 0; + /** When set, resumeLatest throws SafeBoundaryResumeParkedError with this reason. */ + parkedResumeReason: TurnResumeParkReason | undefined; contextDiagnosticsRequests = 0; goal: GoalProjection | null = null; readonly goalListeners = new Set<(goal: GoalProjection | null) => void>(); @@ -7480,6 +7621,9 @@ class SlashCommandDriver extends FakeSessionDriver { async *resumeLatest(): AsyncIterable { this.resumeCalls += 1; + if (this.parkedResumeReason !== undefined) { + throw new SafeBoundaryResumeParkedError(this.parkedResumeReason); + } yield { type: 'text_complete', id: 'event-resume-text', diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 51f2ff0630..82a080907a 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -74,7 +74,11 @@ import type { } from './pi-tui-contracts.js'; import { AUTO_RECAP_DISPLAY_LIMIT_BYTES, shouldAutoRecap } from './session-recap.js'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; -import type { AgentGraphClientSnapshot, AgentGraphEpochSummary } from '@maka/runtime-host/protocol'; +import type { + AgentGraphClientSnapshot, + AgentGraphEpochSummary, + TurnResumeParkReason, +} from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import { MakaSkillHighlightEditor } from './skill-highlight-editor.js'; import { parseGraphCommand, type ParsedGraphCommand } from '@maka/core/graph-command'; @@ -87,6 +91,7 @@ import { type MakaSideConversationParentStatus, type MakaSessionSwitchResult, } from './session-driver.js'; +import { SafeBoundaryResumeParkedError } from './runtime-host-session-driver.js'; import { appendTurnFailureToTranscript, appendUserPrompt, @@ -277,6 +282,34 @@ export function resolveTaskbarProgress( return environment.platform !== 'win32' && environment.windowsTerminalSession === undefined; } +/** + * User-facing copy for a parked safe-boundary resume. Parked is the host's + * way of saying "no safe continuation exists right now"; the reasons below + * are informational, so they read as a plain sentence instead of a protocol + * identifier. + */ +export function safeBoundaryResumeParkedCopy(reason: TurnResumeParkReason): { + level: 'info' | 'error'; + text: string; +} { + switch (reason) { + case 'resume_feature_disabled': + return { + level: 'info', + text: 'Safe-boundary resume is not enabled on this runtime (set MAKA_RUNTIME_SAFE_BOUNDARY_RESUME=1 to enable).', + }; + case 'resume_candidate_missing': + return { + level: 'info', + text: 'Nothing to resume: no interrupted run exists in this session.', + }; + case 'session_busy': + return { level: 'info', text: 'Cannot resume: the session already has an active turn.' }; + default: + return { level: 'error', text: `Safe-boundary resume parked: ${reason}` }; + } +} + export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const locale = input.locale ?? 'en'; const primaryGuidance = getTuiPrimaryGuidance(locale); @@ -2251,11 +2284,27 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { text: 'Resuming from the latest safe boundary…', }); requestRender(); - for await (const event of input.driver.resumeLatest()) { - applyMakaSessionEventToTranscript(state, event); - shellRunElapsedTicker.sync(); - syncUserQuestionOverlay(); - requestRender(); + try { + for await (const event of input.driver.resumeLatest()) { + applyMakaSessionEventToTranscript(state, event); + shellRunElapsedTicker.sync(); + syncUserQuestionOverlay(); + requestRender(); + } + } catch (error) { + // Preserve the Host's reason: expected user states are informational, + // while unavailable recovery authority and safety observations stay red. + if (error instanceof SafeBoundaryResumeParkedError) { + const presentation = safeBoundaryResumeParkedCopy(error.reason); + state.entries.push({ + kind: 'notice', + level: presentation.level, + text: presentation.text, + }); + requestRender(); + return; + } + throw error; } }; diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 2ab945eb41..ed5d5506cb 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -71,11 +71,10 @@ import { type GoalControlAction, type GoalProjection, type SessionContinuitySnapshot, + type TurnResumeParkReason, } from '@maka/runtime-host/protocol'; -import { - RuntimeHostSessionChannel, - type RuntimeHostSessionChannelOpenResult, -} from './runtime-host-session-channel.js'; +import { RuntimeHostSessionChannel } from './runtime-host-session-channel.js'; +import type { RuntimeHostSessionChannelOpenResult } from './runtime-host-session-channel.js'; import type { InspectCwdChanges, MakaAttachedSessionTurn, @@ -110,6 +109,21 @@ const decodeStoredMessage = (value: unknown): StoredMessage => decodePersistedStoredMessage(markPersisted(value)); const MAX_CATALOG_ATTEMPTS = 3; +/** + * The host declined to start a safe-boundary continuation and explained why. + * `reason` is the durable park reason from the `turn.resume` protocol, so + * surfaces can tell "nothing to resume" from a real failure. + */ +export class SafeBoundaryResumeParkedError extends Error { + readonly reason: TurnResumeParkReason; + + constructor(reason: TurnResumeParkReason) { + super(`Safe-boundary resume parked: ${reason}`); + this.name = 'SafeBoundaryResumeParkedError'; + this.reason = reason; + } +} + /** Optimistic-control retries for goal pause/resume/clear (mirrors the desktop client). */ const GOAL_CONTROL_MAX_ATTEMPTS = 3; @@ -370,7 +384,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { const sessionId = this.#requireSession('resume'); const plan = await this.#request('turn.resume.query', { sessionId }); if (plan.disposition !== 'ready') { - throw new Error(`Safe-boundary resume parked: ${plan.reason}`); + throw new SafeBoundaryResumeParkedError(plan.reason); } const channel = await this.#ensureChannel(sessionId); const turnId = this.#newId(); @@ -384,7 +398,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, }); if (result.kind !== 'started') { - channel.failTurn(turnId, new Error(`Safe-boundary resume parked: ${result.plan.reason}`)); + channel.failTurn(turnId, new SafeBoundaryResumeParkedError(result.plan.reason)); } } catch (error) { channel.failTurn(turnId, error); diff --git a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts index a0dae34fea..4caff505ca 100644 --- a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts @@ -293,7 +293,7 @@ test('startup parks a provider-indeterminate continuation when resume is disable { sessionId: fixture.sessionId, disposition: 'parked', - reason: 'continuation_unavailable', + reason: 'resume_feature_disabled', }, ); const sibling = requireStartedTurn( @@ -392,7 +392,7 @@ test('Runtime Host keeps safe-boundary continuation opt-in', async () => { const plan = { sessionId: fixture.sessionId, disposition: 'parked' as const, - reason: 'continuation_unavailable' as const, + reason: 'resume_feature_disabled' as const, }; assert.deepEqual( await client.request('turn.resume.query', { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 32d0daa7fa..349c37803d 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1035,6 +1035,25 @@ describe('Runtime Host bootstrap protocol', () => { }, }; assert.deepEqual(decodeHostFrame(parked), parked); + for (const reason of [ + 'resume_feature_disabled', + 'continuation_authority_unavailable', + 'safety_observation_unavailable', + ] as const) { + const unavailable = { + ...parked, + result: { ...parked.result, reason }, + }; + assert.deepEqual(decodeHostFrame(unavailable), unavailable); + } + assert.throws( + () => + decodeHostFrame({ + ...parked, + result: { ...parked.result, reason: 'continuation_unavailable' }, + }), + isInvalidFrame, + ); assert.throws( () => decodeHostFrame({ diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 45adfa103a..aace106c3f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -93,7 +93,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 56 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 57 as const; +// 57: Parked safe-boundary resume plans preserve feature-disabled, missing +// continuation authority, and unavailable safety-observation reasons. +// Older peers collapse these causes and can misclassify recovery failures. // 56: Failed Turn snapshots preserve the structured context-budget exhaustion // detail. Epoch-55 peers reject the optional field on the closed snapshot shape. // 55: Local owners can atomically revoke every credential for one access diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index c96d19e411..2e5e6dd336 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -121,7 +121,9 @@ export const TURN_RESUME_PARK_REASONS = [ 'continuation_already_exists', 'continuation_repair_required', 'continuation_started_indeterminate', - 'continuation_unavailable', + 'resume_feature_disabled', + 'continuation_authority_unavailable', + 'safety_observation_unavailable', 'session_busy', ] as const; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index adbdc14136..6fc1006222 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -408,7 +408,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if (continuation?.disposition === 'parked') { if ( continuation.plan.reason === 'safety_check_failed' || - continuation.plan.reason === 'continuation_unavailable' + continuation.plan.reason === 'resume_feature_disabled' || + continuation.plan.reason === 'continuation_authority_unavailable' || + continuation.plan.reason === 'safety_observation_unavailable' ) { this.parkContinuationAdmission(admission); return undefined; @@ -2854,12 +2856,12 @@ function projectTurnResumePlan( reason = 'continuation_started_indeterminate'; } else if (reasons.has('continuation_claim_repair_required')) { reason = 'continuation_repair_required'; - } else if ( - reasons.has('resume_feature_disabled') || - reasons.has('continuation_authority_unavailable') || - reasons.has('safety_observation_unavailable') - ) { - reason = 'continuation_unavailable'; + } else if (reasons.has('resume_feature_disabled')) { + reason = 'resume_feature_disabled'; + } else if (reasons.has('continuation_authority_unavailable')) { + reason = 'continuation_authority_unavailable'; + } else if (reasons.has('safety_observation_unavailable')) { + reason = 'safety_observation_unavailable'; } else { reason = 'safety_check_failed'; }