Skip to content
Open
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
39 changes: 37 additions & 2 deletions docs/architecture/runtime-resume-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,36 @@ CLI/TUI `/resume` uses the same `SessionManager` plan/execute seam. Startup
recovery can reconstruct an already admitted continuation through that seam,
but it does not automatically select an ordinary failed or cancelled Run.

### A missing tool result is projected only for a fresh user turn

An uncertain tool side effect is not a resumable boundary. If recovery finds a
durable T1 dispatch without a committed T2 result, it seals the old invocation
as `outcome_unknown`; it does not invent a durable tool response and does not
retry the tool. The old Run remains stopped.

When the user later sends a new explicit message, that message starts a new
Turn. Only provider requests in that fresh Turn receive a temporary history
projection: the old tool call is paired with an `outcome_unknown` response and
a system notice, then the new user message follows. The temporary response and
notice are not written to the RuntimeEvent ledger or transcript. For example, after
`Bash("touch marker.txt")` was dispatched but its result was not committed, a
new request such as “check whether `marker.txt` exists” lets the model inspect
the current state before deciding what to do; Maka does not decide whether the
write happened and does not retry it automatically.

Automated triggers such as cloud activation, schedules, Goals, WorkHub results,
and Agent Graph wakes are not explicit user messages. If such a fresh Turn sees
an unresolved unknown tool outcome, Runtime rejects it before making a provider
request; a user must inspect the current state and send a new message.

This projection must not lose the T1 call. If an existing checkpoint or the
current context budget would hide it, Runtime falls back to the full effective
history for that request. This is a deliberate fail-closed trade-off: a very
large unresolved history may exceed the provider's context limit, in which case
the provider's real error is surfaced rather than dropping the uncertainty or
claiming success. Compaction that preserves this uncertainty while reducing
the rest of that history is not implemented here.

### Current parked-reason boundary

Runtime Host projects Runtime planner rejection reasons into the closed
Expand Down Expand Up @@ -833,14 +863,19 @@ Start with production-shaped red tests, then land core contract, storage constra

| Setting | Purpose | Rollback meaning |
|---|---|---|
| unset | Enable explicit Desktop and CLI/TUI resume; keep model-driven WorkHub resume disabled | Default product behavior |
| `MAKA_RUNTIME_SAFE_BOUNDARY_RESUME=1` | Also enable model-driven WorkHub resume | Preserves the previous full opt-in behavior |
| unset | Enable explicit Desktop and CLI/TUI resume; keep automated WorkHub and `maka activate` resume disabled | Default product behavior |
| `MAKA_RUNTIME_SAFE_BOUNDARY_RESUME=1` | Also enable automated WorkHub and `maka activate` resume | Preserves the previous full opt-in behavior |
| `MAKA_RUNTIME_SAFE_BOUNDARY_RESUME=0` | Disable explicit and model-driven resume planning | May park reconstruction; does not delete durable facts |

Unknown non-empty values fail closed like `0`. Every enabled entry point still
uses the same authoritative planner; the policy only controls whether a new
resume attempt may reach it.

With automated `maka activate` resume enabled, a ready continuation is used
instead of submitting that activation's new stimulus. If no continuation is
ready, the stimulus is submitted normally. Callers that need every activation
stimulus processed should not opt into this legacy resume behavior.

RuntimeEvent migration is unconditional on the first write. Downgrading to a
reader that does not understand the new schema requires explicit, verified
export. Migration failure must preserve legacy JSONL. A newer database schema
Expand Down
26 changes: 24 additions & 2 deletions docs/architecture/runtime-resume-architecture.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,26 @@ sequenceDiagram
CLI/TUI 的 `/resume` 走同一个 `SessionManager` plan/execute seam。启动恢复也会通过这条
seam 重建已经 admission 的 continuation,但不会自动选择普通的 failed 或 cancelled Run。

### 工具已派发但没有已提交结果:只在新用户 Turn 中临时投影

工具副作用结果未知时,旧 Run 不能安全续跑。如果恢复时发现 T1 派发事实已经写入,但没有已提交的
T2 结果,Runtime 会把旧 invocation 封存为 `outcome_unknown`;它不会伪造一条 durable tool response,
也不会重试该工具。旧 Run 仍然是停止状态。

之后用户显式发送新消息时,Runtime 会开启一个新的 Turn。只有这个新 Turn 中发给模型的请求会看到临时的
历史投影:旧工具调用后面暂时附上一条 `outcome_unknown` 响应和 system 提醒,再接上新用户消息。这条临时响应
和提醒不会写入 RuntimeEvent ledger 或 transcript。例如,`Bash("touch marker.txt")` 已派发、但结果来不及提交;
用户新消息可以说“检查 `marker.txt` 是否存在”。模型可以先检查当前状态,再决定下一步;Maka 不会替模型
判断文件是否写入,也不会自动重试命令。

云端 activation、定时任务、Goal、WorkHub 结果和 Agent Graph 唤醒都不是用户显式消息。如果这类新 Turn
遇到尚未确定结果的工具调用,Runtime 会在请求模型之前拒绝继续;需要用户检查当前状态并发送新消息。

这个投影不能丢掉 T1 调用。如果已有 checkpoint 或当前 context budget 会把它隐藏,Runtime 会为这次请求
退回完整的 effective history。这是有意的 fail-closed 取舍:未解决的历史很大时,可能超过 provider 的上下文
限制;此时返回 provider 的真实错误,而不是删掉不确定事实或谎称工具成功。当前实现还不能在压缩其余历史的同时
保留这份不确定性。

### 当前的 parked 原因边界

Runtime Host 负责把 Runtime planner 的 rejection reasons 投影成封闭的
Expand Down Expand Up @@ -864,13 +884,15 @@ RuntimeEvent 迁移不再由开关控制;首次写入必然迁移。当前恢

| 设置 | 作用 | 回滚含义 |
|---|---|---|
| 未设置 | 开启 Desktop 与 CLI/TUI 的显式 resume;保持 WorkHub 模型驱动 resume 关闭 | 默认产品行为 |
| `MAKA_RUNTIME_SAFE_BOUNDARY_RESUME=1` | 额外开启 WorkHub 模型驱动 resume | 保留此前完整 opt-in 行为 |
| 未设置 | 开启 Desktop 与 CLI/TUI 的显式 resume;保持 WorkHub 和 `maka activate` 自动 resume 关闭 | 默认产品行为 |
| `MAKA_RUNTIME_SAFE_BOUNDARY_RESUME=1` | 额外开启 WorkHub 和 `maka activate` 自动 resume | 保留此前完整 opt-in 行为 |
| `MAKA_RUNTIME_SAFE_BOUNDARY_RESUME=0` | 关闭显式和模型驱动 resume planning | 可能 park reconstruction,但不会删除 durable facts |

未知的非空值与 `0` 一样 fail closed。所有已开启的入口仍使用同一个权威 planner;策略只决定
新的 resume 尝试能否到达 planner。

启用 `maka activate` 自动 resume 后,若有可续跑的 continuation,会续跑旧 Turn,而不提交本次 activation 的新 stimulus;若没有可续跑项,则正常提交 stimulus。需要确保每次 activation 的新内容都被处理时,不应启用这项旧式自动续跑行为。

真正降级到不理解新 schema 的旧版本前,必须显式 export 并验证。Migration 失败不能删除 legacy JSONL;数据库版本比当前程序新时必须 fail closed。

未来 checkpoint 和 recovery capability 也应遵守同一原则:一旦在 T1 或 accepted boundary 前选定 durable mode,不能在执行中静默 fallback 到较弱协议。降级必须产生明确、可审计的 mode mismatch 或 park reason。
Expand Down
111 changes: 110 additions & 1 deletion packages/cli/src/__tests__/activation-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { after, before, describe, test } from 'node:test';
import type { SessionEvent } from '@maka/core/events';
import type { UserMessageInput } from '@maka/core/runtime-inputs';
import type { SessionSummary } from '@maka/core/session';
import type { RuntimeHostConnection } from '@maka/runtime-host/client';
import {
decodeActivationRequest,
parseMakaActivateArgs,
Expand All @@ -34,6 +35,8 @@ import {
type MakaActivationRuntime,
} from '../activation-command.js';
import type { MakaRunOutcome } from '../run-command-core.js';
import { createRuntimeHostRunContext } from '../runtime-host-run-command.js';
import type { RuntimeHostMakaSessionDriver } from '../runtime-host-session-driver.js';

const ROOTS = {
stateRoot: '/tmp/maka-state',
Expand Down Expand Up @@ -553,9 +556,10 @@ describe('maka activate JSONL protocol', () => {
}
});

test('keeps a fresh activation stimulus instead of implicitly resuming an existing session', async () => {
test('preserves a new activation stimulus and origin instead of implicitly resuming an existing session', async () => {
let created = false;
let resumed = false;
let sentOrigin: unknown;
let sentText: string | undefined;
let requestedConnection: string | undefined;
const lines: string[] = [];
Expand Down Expand Up @@ -583,6 +587,7 @@ describe('maka activate JSONL protocol', () => {
resumed = true;
},
sendMessage: async function* (_runtime, _sessionId, input) {
sentOrigin = input.origin;
sentText = input.text;
yield* completedEvents();
},
Expand All @@ -594,11 +599,115 @@ describe('maka activate JSONL protocol', () => {
assert.equal(result, 0);
assert.equal(created, false);
assert.equal(resumed, false);
assert.deepEqual(sentOrigin, { kind: 'cloud_activation', activationId: 'activation-1' });
assert.equal(sentText, 'Inspect the workspace');
assert.equal(requestedConnection, 'local');
assert.equal(JSON.parse(lines.at(-1)!).makaSessionId, 'maka-session-1');
});

test('carries activation provenance through the production Runtime Host adapter', async () => {
const prepared: Array<{ prompt: string; origin: unknown }> = [];
const driver = {
switchSession: async (sessionId: string) => ({
summary: summary({ id: sessionId }),
messages: [],
}),
preparePrompt: async (prompt: string, options: { origin?: unknown }) => {
prepared.push({ prompt, origin: options.origin });
return {
sessionId: 'maka-session-1',
turnId: 'turn-1',
runId: 'run-1',
events: (async function* () {
yield {
type: 'text_complete',
id: 'event-text',
turnId: 'turn-1',
messageId: 'message-1',
ts: 1,
text: 'done',
};
yield {
type: 'complete',
id: 'event-complete',
turnId: 'turn-1',
ts: 2,
stopReason: 'end_turn',
};
})(),
};
},
subscribePendingInteractions: () => () => {},
subscribeTranscriptReplacements: () => () => {},
} as unknown as RuntimeHostMakaSessionDriver;
const connection = {
request: async (operation: string) => {
throw new Error(`Unexpected Runtime Host operation: ${operation}`);
},
} as unknown as RuntimeHostConnection;
const catalog = {
revision: 1,
defaultTarget: { connectionId: 'connection-1', modelId: 'gpt-5' },
connections: [
{
connectionId: 'connection-1',
revision: 1,
slug: 'openai-main',
name: 'OpenAI',
providerType: 'openai' as const,
enabled: true,
enabledModelIds: ['gpt-5'],
catalogEntries: [],
models: [{ id: 'gpt-5' }],
},
],
};
const deps = fakeDeps({
input: JSON.stringify(validRequest({ makaSessionId: 'maka-session-1' })),
sessions: [summary({ llmConnectionSlug: 'openai-main', model: 'gpt-5' })],
});
const output: string[] = [];
deps.writeStdout = (text) => output.push(text);
deps.createContext = async (input) => {
const context = createRuntimeHostRunContext(
connection,
catalog,
{
workspaceRoot: input.workspaceRoot,
cwd: input.cwd,
...(input.requestedConnectionSlug
? { requestedConnectionSlug: input.requestedConnectionSlug }
: {}),
...(input.requestedModel ? { requestedModel: input.requestedModel } : {}),
...(input.sessionCwdOverride ? { sessionCwdOverride: input.sessionCwdOverride } : {}),
...(input.runOutcomeObserver ? { runOutcomeObserver: input.runOutcomeObserver } : {}),
},
{ createDriver: () => driver },
);
return context;
};

const result = await runMakaActivationCli(
[
'--state-root',
ROOTS.stateRoot,
'--workspace-root',
ROOTS.workspaceRoot,
'--config-root',
ROOTS.configRoot,
],
deps,
);

assert.equal(result, 0, JSON.stringify({ output, prepared }));
assert.deepEqual(prepared, [
{
prompt: 'Inspect the workspace',
origin: { kind: 'cloud_activation', activationId: 'activation-1' },
},
]);
});

test('only opts automated activation into resume when explicitly enabled', async () => {
let resumed = false;
let stimulusSent = false;
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9095,6 +9095,35 @@ Slug openai-work<cursor>
await run;
});

test('/resume starts the latest safe-boundary continuation for the selected session', async () => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
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('resumed safely'));

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();
Expand Down
Loading
Loading