Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/architecture/runtime-resume-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 19 additions & 1 deletion docs/architecture/runtime-resume-architecture.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不复制原用户消息

Expand Down
146 changes: 145 additions & 1 deletion packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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>();
Expand Down Expand Up @@ -7480,6 +7621,9 @@ class SlashCommandDriver extends FakeSessionDriver {

async *resumeLatest(): AsyncIterable<SessionEvent> {
this.resumeCalls += 1;
if (this.parkedResumeReason !== undefined) {
throw new SafeBoundaryResumeParkedError(this.parkedResumeReason);
}
yield {
type: 'text_complete',
id: 'event-resume-text',
Expand Down
61 changes: 55 additions & 6 deletions packages/cli/src/pi-tui-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -87,6 +91,7 @@ import {
type MakaSideConversationParentStatus,
type MakaSessionSwitchResult,
} from './session-driver.js';
import { SafeBoundaryResumeParkedError } from './runtime-host-session-driver.js';
import {
appendTurnFailureToTranscript,
appendUserPrompt,
Expand Down Expand Up @@ -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<void> {
const locale = input.locale ?? 'en';
const primaryGuidance = getTuiPrimaryGuidance(locale);
Expand Down Expand Up @@ -2251,11 +2284,27 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
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) {
Comment thread
me2seeks marked this conversation as resolved.
const presentation = safeBoundaryResumeParkedCopy(error.reason);
state.entries.push({
kind: 'notice',
level: presentation.level,
text: presentation.text,
});
requestRender();
return;
}
throw error;
}
};

Expand Down
26 changes: 20 additions & 6 deletions packages/cli/src/runtime-host-session-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -110,6 +109,21 @@ const decodeStoredMessage = (value: unknown): StoredMessage =>
decodePersistedStoredMessage(markPersisted<StoredMessage>(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;

Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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', {
Expand Down
Loading