Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aee559e
test(runtime-host): prove workspace-bound crash recovery
zhiiw Aug 24, 2026
ed19219
test(runtime-host): seed canonical continuation runs
zhiiw Aug 24, 2026
e7c4670
test(runtime-host): report continuation parking reason
zhiiw Aug 24, 2026
45bc4a4
test(runtime-host): compose full managed crash profile
zhiiw Aug 24, 2026
107767c
test(runtime-host): expose continuation lifecycle diagnostics
zhiiw Aug 24, 2026
e4fd853
docs(runtime): record complete continuation test profile
zhiiw Aug 24, 2026
06b268a
test(runtime-host): expose continuation safety failures
zhiiw Aug 24, 2026
87ce43e
fix(runtime-host): adopt workspace root before resume inspection
zhiiw Aug 24, 2026
6006157
fix(storage): persist workspace continuation replay identity
zhiiw Aug 24, 2026
3fb042e
fix(runtime-host): validate workspace continuation replay proof
zhiiw Aug 24, 2026
caefc70
test(runtime-host): report recovered continuation state
zhiiw Aug 24, 2026
aa77a7a
test(runtime-host): assert crashed continuation closure
zhiiw Aug 24, 2026
12b8c4f
fix(runtime): preserve indeterminate continuation after restart
zhiiw Aug 24, 2026
f2739a1
test(runtime-host): retry parked continuation on new turn
zhiiw Aug 24, 2026
360076f
docs(runtime): record continuation restart closure semantics
zhiiw Aug 24, 2026
3932693
fix(runtime-host): retain continuation root ownership
zhiiw Aug 24, 2026
0e88cdd
test(runtime-host): prove provider-once continuation recovery
zhiiw Aug 24, 2026
c0ae9f2
ci(gitoxide): use supported managed npm Node
zhiiw Aug 24, 2026
db5a9d4
ci(runtime-host): provision managed continuation sandboxes
zhiiw Aug 24, 2026
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
41 changes: 39 additions & 2 deletions .github/workflows/gitoxide-helper-admission.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,19 @@ on:
- 'packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts'
- 'packages/runtime-host/src/server/gitoxide-managed-*.ts'
- 'packages/runtime-host/src/__tests__/gitoxide-managed-*.test.ts'
- 'packages/runtime-host/src/server/execution-composition.ts'
- 'packages/runtime-host/src/__tests__/fixtures/execution-host.ts'
- 'packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts'
- 'experiments/windows-sandbox/launcher/**'
- 'packages/storage/src/execution-stores.ts'
- 'packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts'
- 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts'
- 'scripts/prepare-gitoxide-helper*'
- 'scripts/gitoxide-helper-workflow-policy.test.mjs'
- 'scripts/generate-gitoxide-cargo-notices.mjs'
- 'apps/desktop/electron-builder.config.mjs'
- 'docs/architecture/gitoxide-*.md'
- 'docs/architecture/runtime-managed-workspace-continuation-*.md'
push:
branches:
- main
Expand All @@ -46,12 +53,19 @@ on:
- 'packages/runtime-host/src/__tests__/gitoxide-managed-inspection.test.ts'
- 'packages/runtime-host/src/server/gitoxide-managed-*.ts'
- 'packages/runtime-host/src/__tests__/gitoxide-managed-*.test.ts'
- 'packages/runtime-host/src/server/execution-composition.ts'
- 'packages/runtime-host/src/__tests__/fixtures/execution-host.ts'
- 'packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts'
- 'experiments/windows-sandbox/launcher/**'
- 'packages/storage/src/execution-stores.ts'
- 'packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts'
- 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts'
- 'scripts/prepare-gitoxide-helper*'
- 'scripts/gitoxide-helper-workflow-policy.test.mjs'
- 'scripts/generate-gitoxide-cargo-notices.mjs'
- 'apps/desktop/electron-builder.config.mjs'
- 'docs/architecture/gitoxide-*.md'
- 'docs/architecture/runtime-managed-workspace-continuation-*.md'

permissions:
contents: read
Expand All @@ -75,8 +89,25 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.19.0
# The packaged managed-npm authority rejects Node 22 before 22.22.2.
node-version: 22.22.2
cache: npm
- name: Install Linux filesystem sandbox
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y bubblewrap
- name: Enable bubblewrap user namespaces
if: runner.os == 'Linux'
run: |
if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
if [[ -e /proc/sys/kernel/unprivileged_userns_clone ]]; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
- name: Build the Windows filesystem sandbox
if: runner.os == 'Windows'
working-directory: experiments/windows-sandbox/launcher
run: cargo build --locked
- name: Check Rust formatting
working-directory: native/gitoxide-helper
run: cargo fmt --check
Expand All @@ -90,8 +121,11 @@ jobs:
- name: Prepare and license the packaged helper
run: >-
npm run prepare:gitoxide-helper &&
npm run prepare:bundled-npm &&
npm run generate:gitoxide-cargo-notices &&
node --test scripts/prepare-gitoxide-helper.test.mjs
node --test
scripts/prepare-gitoxide-helper.test.mjs
scripts/gitoxide-helper-workflow-policy.test.mjs
- name: Build the helper invocation owner
run: >-
npm --workspace @maka/core run build &&
Expand All @@ -101,12 +135,15 @@ jobs:
- name: Test the real helper invocation contract
env:
MAKA_GITOXIDE_HELPER_PATH: ${{ github.workspace }}/native/gitoxide-helper/target/debug/maka-gitoxide-helper${{ runner.os == 'Windows' && '.exe' || '' }}
MAKA_BUNDLED_NPM_RESOURCES_ROOT: ${{ github.workspace }}/apps/desktop/.generated/bundled-npm
MAKA_WINDOWS_SANDBOX_PATH: ${{ github.workspace }}/experiments/windows-sandbox/launcher/target/debug/maka-windows-sandbox.exe
run: >-
node --test
packages/runtime-host/dist/__tests__/gitoxide-helper-artifact-authority-internal.test.js
packages/runtime-host/dist/__tests__/gitoxide-helper-invocation-internal.test.js
packages/runtime-host/dist/__tests__/gitoxide-helper-mutation-candidate-authority-internal.test.js
packages/runtime-host/dist/__tests__/gitoxide-repository-admission-authority-internal.test.js
packages/runtime-host/dist/__tests__/gitoxide-managed-inspection.test.js
packages/runtime-host/dist/__tests__/gitoxide-managed-continuation-crash.test.js
packages/runtime-host/dist/__tests__/gitoxide-managed-mutation-admission.test.js
packages/runtime-host/dist/__tests__/gitoxide-managed-mutation-session.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Managed workspace continuation crash evidence v1

> 状态:stacked Draft。依赖 M3.2 workspace-bound continuation admission;不启用 Desktop/CLI 自动续跑。

## 主要不变量

`managed-coding-v1` continuation 的 durable start 一旦提交,Runtime Host 崩溃后只能基于同一条 `ContinuationClaimV2` 和同一个 Gitoxide accepted head 收敛。重启不得重新调用 provider,也不得回退到 v1 continuation authority。

## Owner 与原子性边界

- SQLite RuntimeEvent ledger 拥有 continuation claim、start fact 和 accepted workspace head。
- Gitoxide helper 只重验 claim 绑定的 commit/tree;它不决定是否重新执行 provider。
- Runtime Host startup recovery 读取 v2 claim state,并在 provider dispatch 已不可判定时停在 `continuation_started_indeterminate`。
- `openInteractiveExecutionStoresForWrite()` 的受限 RuntimeEvent facade 显式暴露 v2 capability 和唯一的一组 v2 writer;不存在旁路 writer。
- packaged Gitoxide capability 通过验证后,Host 在 continuation safety inspection 之前采用已持久化的 storage-root binding;重启不依赖某个 backend 先被创建。
- AgentRun root admission 对 legacy descriptor 保持原协议,同时严格保存 workspace-bound descriptor 独立的 `replayManifestDigest`。

原子性边界是 `commitWorkspaceBoundContinuationStart()` 的 SQLite transaction。该 transaction 之前可以安全重试 admission;提交之后不得根据进程内状态推断 provider 是否执行。

## 失败状态与回滚

| 状态 | 处理 |
| --- | --- |
| claim/start 绑定的 workspace head 与当前 accepted head 不一致 | fail closed;不启动 provider |
| durable start 已存在、provider outcome 不可证明 | park 为 `continuation_started_indeterminate` |
| Host 将未终结 target Run 关闭为 `failed/app_restarted` | 该本地关闭事实不证明 provider 未收到请求;v2 claim 仍保持 `continuation_started_indeterminate` |
| v2 authority/capability 缺失 | fail closed;禁止回退 v1 |
| Gitoxide helper/manifest 不可验证 | managed continuation unavailable;不启动 provider |

这里没有“回滚 durable start”。它是不可变事实。人工终止、未来 provider reattach 或其他恢复策略必须另外提交新的明确事实,不能改写旧 start。

## Production-shaped crash proof

三平台 gate 使用与当前源码匹配的 release Gitoxide helper,并同时装配发布形状的 bundled npm runtime。后者不是 continuation 的事实源,但 `managed-coding-v1` 必须先完整解析实际工具目录;测试不能通过删减工具面来绕过这项安全检查。随后执行下面的真实进程序列:

1. 创建 SHA-1 source repository 和 `managed-coding-v1` Session;
2. 通过真实 Gitoxide helper 创建并重验 accepted baseline;
3. 启动真实 Runtime Host,提交 workspace-bound claim 与 durable start;
4. 在 `after_continuation_start_committed` 杀死整个 Host 进程;
5. 使用同一 storage root 与 helper 启动新 Host;
6. 验证 target Run 被 Host 明确关闭为 `failed/app_restarted`,但该本地终态不会覆盖 provider T1 的不确定性;
7. 使用新的 Turn identity 重试同一 source boundary,验证结果仍为 `continuation_started_indeterminate`;
8. 验证 claim 仍绑定原 commit/tree/revision,provider invocation log 始终为空。

同一 gate 还覆盖更强的未知结果窗口:先通过一次 durable managed Write 将 accepted head 推进到
revision 2,再启动 continuation;Fake provider 已记录一次真实 `send()` 调用、但尚未产生 durable
terminal 时杀死 Host。重启与新 Turn 重试都必须保持
`continuation_started_indeterminate`,provider invocation log 的计数必须始终等于 1。这样既证明
successor workspace boundary 可恢复,也证明“请求可能已经到达 provider”时绝不重发。

这项测试有意不声称可以恢复 provider 的网络执行。它证明的是“未知时不重发”,不是 bit-exact provider continuation。

## 平台能力矩阵

| 平台 | v1 承诺 |
| --- | --- |
| Linux | 真实 helper、Runtime Host kill/reopen、0 次 provider replay |
| macOS | 真实 helper、Runtime Host kill/reopen、0 次 provider replay |
| Windows | 真实 helper、Runtime Host kill/reopen、0 次 provider replay |

证据由 `.github/workflows/gitoxide-helper-admission.yml` 的三平台 matrix 持续执行。CI 绿只证明这些已枚举边界;它不扩大到断电恢复、provider reattach 或 Desktop 自动续跑。
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ export interface ExecutionHostHandle {
recoveryOutcome?: RuntimeEvent;
}

export interface ExecutionHostTestOptions {
readonly packagedResourcesRoot?: string;
readonly providerCallLogPath?: string;
readonly continuationFailpoint?:
| 'after_continuation_claim_committed'
| 'after_run_created'
| 'after_continuation_start_committed';
readonly providerFailpointAfterSend?: boolean;
}

export interface TurnLedger {
runs: AgentRunHeader[];
userMessages: Array<Extract<StoredMessage, { type: 'user' }>>;
Expand Down Expand Up @@ -858,8 +868,9 @@ export class ExecutionFixture {
runId: string;
},
safeBoundaryResumeEnabled = true,
testOptions: ExecutionHostTestOptions = {},
): Promise<ExecutionHostHandle> {
const child = this.spawnHost('inherit', recoveryProbe, safeBoundaryResumeEnabled);
const child = this.spawnHost('inherit', recoveryProbe, safeBoundaryResumeEnabled, testOptions);
const ready = await waitForHostReady(child);
return { child, ...ready };
}
Expand Down Expand Up @@ -1005,10 +1016,31 @@ export class ExecutionFixture {
stderr: 'inherit' | 'ignore',
recoveryProbe?: { sessionId: string; runId: string },
safeBoundaryResumeEnabled = true,
testOptions: ExecutionHostTestOptions = {},
): ChildProcess {
const env = { ...process.env };
if (safeBoundaryResumeEnabled) env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME = '1';
else delete env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME;
if (testOptions.packagedResourcesRoot) {
env.MAKA_TEST_PACKAGED_RESOURCES_ROOT = testOptions.packagedResourcesRoot;
} else {
delete env.MAKA_TEST_PACKAGED_RESOURCES_ROOT;
}
if (testOptions.providerCallLogPath) {
env.MAKA_TEST_PROVIDER_CALL_LOG = testOptions.providerCallLogPath;
} else {
delete env.MAKA_TEST_PROVIDER_CALL_LOG;
}
if (testOptions.continuationFailpoint) {
env.MAKA_TEST_CONTINUATION_FAILPOINT = testOptions.continuationFailpoint;
} else {
delete env.MAKA_TEST_CONTINUATION_FAILPOINT;
}
if (testOptions.providerFailpointAfterSend) {
env.MAKA_TEST_PROVIDER_FAILPOINT_AFTER_SEND = '1';
} else {
delete env.MAKA_TEST_PROVIDER_FAILPOINT_AFTER_SEND;
}
const child = fork(
new URL('./execution-host.js', import.meta.url),
[
Expand Down
44 changes: 42 additions & 2 deletions packages/runtime-host/src/__tests__/fixtures/execution-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
* under the License.
*/

import { join } from 'node:path';
import { appendFile } from 'node:fs/promises';
import { isAbsolute, join } from 'node:path';
import { inspect } from 'node:util';
import { FakeBackend } from '@maka/runtime/test-only/fake-backend';
import { createSqliteRuntimeStore } from '@maka/storage';
Expand All @@ -40,6 +41,38 @@ if (!Number.isSafeInteger(idleGraceMs) || idleGraceMs < 0) {
throw new Error('execution-host requires a non-negative idle grace');
}

const packagedResourcesRoot = process.env.MAKA_TEST_PACKAGED_RESOURCES_ROOT;
if (packagedResourcesRoot) {
if (!isAbsolute(packagedResourcesRoot)) {
throw new Error('MAKA_TEST_PACKAGED_RESOURCES_ROOT must be absolute');
}
Object.defineProperty(process.versions, 'electron', {
configurable: true,
value: 'test-runtime-host',
});
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: packagedResourcesRoot,
});
}

const providerCallLogPath = process.env.MAKA_TEST_PROVIDER_CALL_LOG;
const continuationFailpoint = process.env.MAKA_TEST_CONTINUATION_FAILPOINT;
const providerFailpointAfterSend = process.env.MAKA_TEST_PROVIDER_FAILPOINT_AFTER_SEND === '1';

class ObservedFakeBackend extends FakeBackend {
override async *send(input: Parameters<FakeBackend['send']>[0]) {
if (providerCallLogPath) {
await appendFile(providerCallLogPath, `${input.turnId}\n`, 'utf8');
}
if (providerFailpointAfterSend) {
process.send?.({ type: 'test.provider_failpoint', point: 'after_send_called' });
await new Promise<never>(() => undefined);
}
yield* super.send(input);
}
}

// The production composition registers no test backend. This fixture is a
// candidate host in its own right, so it supplies the deterministic one through
// the composition's `primaryBackendFactory` seam — the same path Desktop E2E
Expand All @@ -53,7 +86,14 @@ const result = await startExecutionRuntimeHostCandidate(
{
createComposition: (context, compositionOptions) =>
createExecutionRuntimeHostComposition(context, compositionOptions, {
primaryBackendFactory: (backendContext) => new FakeBackend(backendContext),
primaryBackendFactory: (backendContext) => new ObservedFakeBackend(backendContext),
continuationFailpoint: continuationFailpoint
? async (point) => {
if (point !== continuationFailpoint) return;
process.send?.({ type: 'test.continuation_failpoint', point });
await new Promise<never>(() => undefined);
}
: undefined,
}),
},
);
Expand Down
Loading
Loading