From aee559ebb58b100dba23b96ed4b5cda1acd90f81 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:02:23 +0800 Subject: [PATCH 01/19] test(runtime-host): prove workspace-bound crash recovery --- .../workflows/gitoxide-helper-admission.yml | 11 + ...ce-continuation-crash-evidence-v1.zh-CN.md | 52 +++ .../fixtures/execution-host-suite.ts | 30 +- .../src/__tests__/fixtures/execution-host.ts | 39 ++- ...itoxide-managed-continuation-crash.test.ts | 307 ++++++++++++++++++ .../src/server/execution-composition.ts | 10 + packages/storage/src/execution-stores.ts | 24 +- 7 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index ee913d24c7..f09149ec59 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -28,12 +28,17 @@ 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' + - '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/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 @@ -46,12 +51,17 @@ 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' + - '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/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 @@ -108,5 +118,6 @@ jobs: 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 diff --git a/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md new file mode 100644 index 0000000000..52f06d0d24 --- /dev/null +++ b/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md @@ -0,0 +1,52 @@ +# 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。 + +原子性边界是 `commitWorkspaceBoundContinuationStart()` 的 SQLite transaction。该 transaction 之前可以安全重试 admission;提交之后不得根据进程内状态推断 provider 是否执行。 + +## 失败状态与回滚 + +| 状态 | 处理 | +| --- | --- | +| claim/start 绑定的 workspace head 与当前 accepted head 不一致 | fail closed;不启动 provider | +| durable start 已存在、provider outcome 不可证明 | park 为 `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,并完成下面的真实进程序列: + +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. 验证 claim 仍绑定原 commit/tree/revision,恢复结果为 `continuation_started_indeterminate`; +7. 验证 provider invocation log 始终为空。 + +这项测试有意不声称可以恢复 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 自动续跑。 + diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 567f2ccb0f..ee163050a9 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -117,6 +117,15 @@ 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'; +} + export interface TurnLedger { runs: AgentRunHeader[]; userMessages: Array>; @@ -172,6 +181,7 @@ export class ExecutionFixture { const sourceTurnId = randomUUID(); const createdAt = Date.now(); const workspace = await resolveWorkspaceIdentity({ path: this.root }); + const session = await stores.sessionStore.readHeaderSnapshot(this.sessionId); const sourceRun: AgentRunHeader = { runId: sourceRunId, invocationId: sourceInvocationId, @@ -185,6 +195,7 @@ export class ExecutionFixture { workspaceIdentity: workspace.workspaceIdentity, permissionMode: 'ask', collaborationMode: 'agent', + ...(session.toolProfile ? { toolProfile: session.toolProfile } : {}), createdAt, updatedAt: createdAt, }; @@ -858,8 +869,9 @@ export class ExecutionFixture { runId: string; }, safeBoundaryResumeEnabled = true, + testOptions: ExecutionHostTestOptions = {}, ): Promise { - const child = this.spawnHost('inherit', recoveryProbe, safeBoundaryResumeEnabled); + const child = this.spawnHost('inherit', recoveryProbe, safeBoundaryResumeEnabled, testOptions); const ready = await waitForHostReady(child); return { child, ...ready }; } @@ -1005,10 +1017,26 @@ 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; + } const child = fork( new URL('./execution-host.js', import.meta.url), [ diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts index dea303bf6f..6eed6ccabb 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts @@ -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'; @@ -40,6 +41,33 @@ 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; + +class ObservedFakeBackend extends FakeBackend { + override async *send(input: Parameters[0]) { + if (providerCallLogPath) { + await appendFile(providerCallLogPath, `${input.turnId}\n`, 'utf8'); + } + 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 @@ -53,7 +81,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(() => undefined); + } + : undefined, }), }, ); diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts new file mode 100644 index 0000000000..9879767953 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -0,0 +1,307 @@ +/* + * 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 type { ChildProcess } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmod, + copyFile, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { createSqliteRuntimeStore } from '@maka/storage'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { requireExecutionStoresWorkspaceMutationAuthorityInternal } from '@maka/storage/execution-stores-workspace-authority-internal'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { + admitGitoxideHelperArtifactInternal, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + inspectGitoxideManagedContinuationBoundary, + openGitoxideManagedMutationSession, +} from '../server/gitoxide-managed-mutation-session.js'; +import { + connectClient, + ExecutionFixture, + PROCESS_TIMEOUT_MS, + withTimeout, +} from './fixtures/execution-host-suite.js'; + +test('a started workspace-bound continuation survives Host death without provider replay', async (t) => { + const helperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!helperPath) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper continuation test'); + return; + } + + await withManagedContinuationFixture( + helperPath, + async ({ fixture, resourcesRoot, callLog, boundary }) => { + const source = await fixture.seedSafeBoundaryContinuationSource(); + const crashHost = await fixture.startHost(undefined, true, { + packagedResourcesRoot: resourcesRoot, + providerCallLogPath: callLog, + continuationFailpoint: 'after_continuation_start_committed', + }); + const crashClient = await connectClient(fixture.root); + const targetTurnId = 'turn-workspace-bound-host-crash'; + try { + assert.equal( + (await crashClient.queryTurnResume({ sessionId: fixture.sessionId })).disposition, + 'ready', + ); + const failpoint = waitForContinuationFailpoint(crashHost.child); + const start = crashClient + .startTurnResume({ + sessionId: fixture.sessionId, + turnId: targetTurnId, + sourceRunId: source.sourceRunId, + sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + }) + .then( + () => undefined, + () => undefined, + ); + await failpoint; + await fixture.killHost(crashHost); + await withTimeout(start, PROCESS_TIMEOUT_MS, 'crashed continuation request did not close'); + } finally { + await crashClient.close().catch(() => undefined); + } + + assert.equal(await readFile(callLog, 'utf8'), ''); + const admission = (await fixture.readAdmissionChain()).find( + (candidate) => candidate.turnId === targetTurnId, + ); + assert.equal(admission?.execution.kind, 'safe_boundary_continuation'); + if (admission?.execution.kind !== 'safe_boundary_continuation') { + assert.fail('Workspace-bound continuation admission is missing'); + } + + const successorHost = await fixture.startHost(undefined, true, { + packagedResourcesRoot: resourcesRoot, + providerCallLogPath: callLog, + }); + const successorClient = await connectClient(fixture.root); + try { + const target = await successorClient.queryTurn({ + sessionId: fixture.sessionId, + turnId: targetTurnId, + }); + assert.equal(target.runId, admission.runId); + assert.equal(target.status === 'created' || target.status === 'running', true); + assert.deepEqual(await successorClient.queryTurnResume({ sessionId: fixture.sessionId }), { + sessionId: fixture.sessionId, + disposition: 'parked', + reason: 'continuation_started_indeterminate', + }); + assert.equal(await readFile(callLog, 'utf8'), ''); + } finally { + await successorClient.close(); + await fixture.stopHost(successorHost); + } + + const store = createSqliteRuntimeStore(join(fixture.root, 'runtime.sqlite'), { + readOnly: true, + }); + try { + const state = await store.readWorkspaceBoundContinuationClaimStateByBoundary( + admission.execution.boundaryDigest, + ); + assert.equal(state?.claim.protocol, 'continuation_claim_v2'); + assert.equal(state?.startKind, 'runtime_admission'); + assert.ok(state?.startEventId); + assert.equal(state?.claim.workspaceBoundary.commitOid, boundary.commitOid); + assert.equal(state?.claim.workspaceBoundary.treeOid, boundary.treeOid); + assert.equal(state?.claim.workspaceBoundary.revision, boundary.revision); + } finally { + store.close(); + } + }, + ); +}); + +async function withManagedContinuationFixture( + helperInputPath: string, + run: (input: { + fixture: ExecutionFixture; + resourcesRoot: string; + callLog: string; + boundary: NonNullable>>; + }) => Promise, +): Promise { + const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-continuation-'))); + const root = join(base, 'root'); + const callLog = join(base, 'provider-calls.log'); + await mkdir(root); + await writeFile(callLog, '', 'utf8'); + git(root, ['init', '--quiet', '--object-format=sha1']); + await writeFile(join(root, 'notes.txt'), 'baseline\n', 'utf8'); + git(root, ['add', 'notes.txt']); + git(root, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'baseline', + ]); + + const resourcesRoot = await preparePackagedHelper(base, helperInputPath); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) throw new Error('Unable to own managed continuation fixture root'); + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + let sessionId: string; + let boundary: NonNullable>>; + try { + const session = await stores.sessionStore.create({ + cwd: root, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + toolProfile: 'managed-coding-v1', + }); + sessionId = session.id; + const helper = await admitRealHelper(helperInputPath); + await openGitoxideManagedMutationSession({ + storageRoot: capability.canonicalPath, + sourceRoot: root, + sessionId, + ...helper, + settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + }); + const observedBoundary = await inspectGitoxideManagedContinuationBoundary({ + storageRoot: capability.canonicalPath, + sourceRoot: root, + sessionId, + ...helper, + settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + }); + assert.ok(observedBoundary); + boundary = observedBoundary; + } finally { + await stores.sessionStore.close?.(); + await owner.close(); + } + + const fixture = new ExecutionFixture(base, root, capability, sessionId); + try { + await run({ fixture, resourcesRoot, callLog, boundary: boundary! }); + } finally { + await fixture.close(); + } +} + +async function preparePackagedHelper(base: string, helperInputPath: string): Promise { + const resourcesRoot = join(base, 'resources'); + const helperDirectory = join(resourcesRoot, 'gitoxide'); + const executableName = + process.platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper'; + const executablePath = join(helperDirectory, executableName); + await mkdir(helperDirectory, { recursive: true }); + await copyFile(await realpath(helperInputPath), executablePath); + if (process.platform !== 'win32') await chmod(executablePath, 0o755); + const [bytes, info] = await Promise.all([readFile(executablePath), stat(executablePath)]); + await writeFile( + join(resourcesRoot, 'gitoxide-helper.json'), + `${JSON.stringify({ + schemaVersion: 1, + protocol: 'maka_gitoxide_helper_release_v1', + provider: 'maka/gitoxide-helper', + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + executableRelativePath: `gitoxide/${executableName}`, + bytes: info.size, + sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + distributionReady: true, + })}\n`, + 'utf8', + ); + return resourcesRoot; +} + +async function admitRealHelper(helperInputPath: string) { + const executablePath = await realpath(helperInputPath); + const [bytes, info] = await Promise.all([readFile(executablePath), stat(executablePath)]); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: info.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + return { invocationOwnerToken, helperCapability }; +} + +function waitForContinuationFailpoint(child: ChildProcess): Promise { + return withTimeout( + new Promise((resolve, reject) => { + const onMessage = (message: unknown): void => { + if ( + message && + typeof message === 'object' && + (message as { type?: unknown }).type === 'test.continuation_failpoint' + ) { + cleanup(); + resolve(); + } + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + cleanup(); + reject(new Error(`Runtime Host exited before continuation failpoint: ${code ?? signal}`)); + }; + const cleanup = (): void => { + child.off('message', onMessage); + child.off('exit', onExit); + }; + child.on('message', onMessage); + child.on('exit', onExit); + }), + PROCESS_TIMEOUT_MS * 3, + 'Runtime Host did not reach the continuation failpoint', + ); +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index cbf78021fe..c8c2bb9179 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -202,6 +202,15 @@ export interface CreateExecutionRuntimeHostCompositionOptions { export interface ExecutionRuntimeHostCompositionDependencies { readonly primaryBackendFactory?: BackendFactory; + /** Production-shaped crash-test seam; product composition never supplies it. */ + readonly continuationFailpoint?: ( + point: + | 'after_continuation_claim_committed' + | 'after_run_created' + | 'after_continuation_start_committed' + | 'after_terminal_event_committed' + | 'after_terminal_header_committed', + ) => Promise; readonly oauthAuthorization?: Pick< HostOAuthCoordinatorInput, 'startCodexAuthorization' | 'pollCodexAuthorization' | 'exchangeCodexCode' @@ -958,6 +967,7 @@ export async function createExecutionRuntimeHostComposition( newId: randomUUID, now: Date.now, safeBoundaryResumeEnabled: process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME === '1', + continuationFailpoint: dependencies.continuationFailpoint, generateSessionTitle: (input) => sessionEffectCoordinator.generateTitle(input), onSessionTitleChanged: (sessionId) => continuityCoordinator.enqueueCanonicalRefresh(sessionId), diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 3c9e2a493e..887f4f3067 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -24,7 +24,10 @@ import type { AgentRunProjectionKey, } from '@maka/core/agent-run'; import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; -import type { RuntimeContinuationAuthorityStore } from '@maka/core/runtime-event-store'; +import type { + RuntimeContinuationAuthorityStore, + RuntimeWorkspaceBoundContinuationAuthorityStore, +} from '@maka/core/runtime-event-store'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import type { SessionListFilter } from '@maka/core/runtime-inputs'; import { @@ -136,7 +139,8 @@ export type { export type ExecutionSessionWriter = SessionAuthorityStore; export type ExecutionAgentRunWriter = DurableAgentRunStore; export type ExecutionRuntimeEventWriter = DurableRuntimeEventStore & - RuntimeContinuationAuthorityStore & { + RuntimeContinuationAuthorityStore & + RuntimeWorkspaceBoundContinuationAuthorityStore & { readonly toolBoundaryProtocol: ToolBoundaryProtocol; commitToolPrepared(input: CommitToolPreparedInput): Promise; commitToolOutcome(input: CommitToolOutcomeInput): Promise; @@ -503,6 +507,8 @@ async function createExecutionStoresForWrite run(() => runtimeEventStore.appendRuntimeEvent(sessionId, runId, event, options)), @@ -537,6 +543,20 @@ async function createExecutionStoresForWrite runtimeEventStore.commitContinuationStart(input)), commitContinuationRepairStart: (input) => run(() => runtimeEventStore.commitContinuationRepairStart(input)), + claimWorkspaceBoundContinuation: (input) => + run(() => runtimeEventStore.claimWorkspaceBoundContinuation(input)), + readWorkspaceBoundContinuationClaimByBoundary: (boundaryDigest) => + run(() => runtimeEventStore.readWorkspaceBoundContinuationClaimByBoundary(boundaryDigest)), + readWorkspaceBoundContinuationClaimStateByBoundary: (boundaryDigest) => + run(() => + runtimeEventStore.readWorkspaceBoundContinuationClaimStateByBoundary(boundaryDigest), + ), + listWorkspaceBoundContinuationClaimsForRecovery: (sessionId) => + run(() => runtimeEventStore.listWorkspaceBoundContinuationClaimsForRecovery(sessionId)), + commitWorkspaceBoundContinuationStart: (input) => + run(() => runtimeEventStore.commitWorkspaceBoundContinuationStart(input)), + commitWorkspaceBoundContinuationRepairStart: (input) => + run(() => runtimeEventStore.commitWorkspaceBoundContinuationRepairStart(input)), readImmutableSteeringMessageProof: (sessionId, messageId) => run(() => runtimeEventStore.readImmutableSteeringMessageProof(sessionId, messageId)), repairImmutableSteeringMessageProofsForRecovery: (sessionId) => From ed1921966221c31933062ec11189b5655fa572ea Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:10:05 +0800 Subject: [PATCH 02/19] test(runtime-host): seed canonical continuation runs --- .../runtime-host/src/__tests__/fixtures/execution-host-suite.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index ee163050a9..786b0acddb 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -181,7 +181,6 @@ export class ExecutionFixture { const sourceTurnId = randomUUID(); const createdAt = Date.now(); const workspace = await resolveWorkspaceIdentity({ path: this.root }); - const session = await stores.sessionStore.readHeaderSnapshot(this.sessionId); const sourceRun: AgentRunHeader = { runId: sourceRunId, invocationId: sourceInvocationId, @@ -195,7 +194,6 @@ export class ExecutionFixture { workspaceIdentity: workspace.workspaceIdentity, permissionMode: 'ask', collaborationMode: 'agent', - ...(session.toolProfile ? { toolProfile: session.toolProfile } : {}), createdAt, updatedAt: createdAt, }; From e7c46703a39d963c879206fce42ce1f939d40495 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:16:40 +0800 Subject: [PATCH 03/19] test(runtime-host): report continuation parking reason --- .../__tests__/gitoxide-managed-continuation-crash.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts index 9879767953..66e5bb4c3b 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -73,10 +73,8 @@ test('a started workspace-bound continuation survives Host death without provide const crashClient = await connectClient(fixture.root); const targetTurnId = 'turn-workspace-bound-host-crash'; try { - assert.equal( - (await crashClient.queryTurnResume({ sessionId: fixture.sessionId })).disposition, - 'ready', - ); + const initialPlan = await crashClient.queryTurnResume({ sessionId: fixture.sessionId }); + assert.equal(initialPlan.disposition, 'ready', JSON.stringify(initialPlan)); const failpoint = waitForContinuationFailpoint(crashHost.child); const start = crashClient .startTurnResume({ From 45bc4a44cd5f1caddb6ab645fc4a17396a82bfed Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:26:16 +0800 Subject: [PATCH 04/19] test(runtime-host): compose full managed crash profile --- .../workflows/gitoxide-helper-admission.yml | 2 ++ ...itoxide-managed-continuation-crash.test.ts | 31 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index f09149ec59..006bd45370 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -100,6 +100,7 @@ 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 - name: Build the helper invocation owner @@ -111,6 +112,7 @@ 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 run: >- node --test packages/runtime-host/dist/__tests__/gitoxide-helper-artifact-authority-internal.test.js diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts index 66e5bb4c3b..2adce876f9 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -23,6 +23,7 @@ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { chmod, + cp, copyFile, mkdir, mkdtemp, @@ -56,13 +57,17 @@ import { test('a started workspace-bound continuation survives Host death without provider replay', async (t) => { const helperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; - if (!helperPath) { - t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper continuation test'); + const bundledNpmResourcesRoot = process.env.MAKA_BUNDLED_NPM_RESOURCES_ROOT; + if (!helperPath || !bundledNpmResourcesRoot) { + t.skip( + 'MAKA_GITOXIDE_HELPER_PATH and MAKA_BUNDLED_NPM_RESOURCES_ROOT are required for the real helper continuation test', + ); return; } await withManagedContinuationFixture( helperPath, + bundledNpmResourcesRoot, async ({ fixture, resourcesRoot, callLog, boundary }) => { const source = await fixture.seedSafeBoundaryContinuationSource(); const crashHost = await fixture.startHost(undefined, true, { @@ -148,6 +153,7 @@ test('a started workspace-bound continuation survives Host death without provide async function withManagedContinuationFixture( helperInputPath: string, + bundledNpmResourcesRoot: string, run: (input: { fixture: ExecutionFixture; resourcesRoot: string; @@ -174,7 +180,11 @@ async function withManagedContinuationFixture( 'baseline', ]); - const resourcesRoot = await preparePackagedHelper(base, helperInputPath); + const resourcesRoot = await preparePackagedResources( + base, + helperInputPath, + bundledNpmResourcesRoot, + ); const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); assert.ok(owner); @@ -221,7 +231,11 @@ async function withManagedContinuationFixture( } } -async function preparePackagedHelper(base: string, helperInputPath: string): Promise { +async function preparePackagedResources( + base: string, + helperInputPath: string, + bundledNpmResourcesInputRoot: string, +): Promise { const resourcesRoot = join(base, 'resources'); const helperDirectory = join(resourcesRoot, 'gitoxide'); const executableName = @@ -247,6 +261,15 @@ async function preparePackagedHelper(base: string, helperInputPath: string): Pro })}\n`, 'utf8', ); + const bundledNpmResourcesRoot = await realpath(bundledNpmResourcesInputRoot); + await copyFile( + join(bundledNpmResourcesRoot, 'bundled-npm.json'), + join(resourcesRoot, 'bundled-npm.json'), + ); + await cp(join(bundledNpmResourcesRoot, 'npm'), join(resourcesRoot, 'npm'), { + recursive: true, + verbatimSymlinks: true, + }); return resourcesRoot; } From 107767cec91888a7049f40f73e8ae0a812c9653f Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:31:33 +0800 Subject: [PATCH 05/19] test(runtime-host): expose continuation lifecycle diagnostics --- .../runtime-host/src/__tests__/fixtures/execution-host.ts | 3 +++ packages/runtime-host/src/server/execution-composition.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts index 6eed6ccabb..b758558d2d 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts @@ -89,6 +89,9 @@ const result = await startExecutionRuntimeHostCandidate( await new Promise(() => undefined); } : undefined, + onContinuationLifecycleEvent: continuationFailpoint + ? (event) => console.error(`[test-continuation] ${JSON.stringify(event)}`) + : undefined, }), }, ); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index c8c2bb9179..f002dc65f9 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -34,6 +34,7 @@ import { BackendRegistry, SessionManager, type BackendFactory, + type RuntimeContinuationLifecycleEvent, } from '@maka/runtime/session-manager'; import { buildToolsForAgentDefinition } from '@maka/runtime/agent-catalog'; import { buildHostCapabilitiesFromBinding } from '@maka/runtime/tool-catalog-derive'; @@ -211,6 +212,10 @@ export interface ExecutionRuntimeHostCompositionDependencies { | 'after_terminal_event_committed' | 'after_terminal_header_committed', ) => Promise; + /** Test/telemetry seam; it observes decisions but owns no durable state. */ + readonly onContinuationLifecycleEvent?: ( + event: RuntimeContinuationLifecycleEvent, + ) => void | Promise; readonly oauthAuthorization?: Pick< HostOAuthCoordinatorInput, 'startCodexAuthorization' | 'pollCodexAuthorization' | 'exchangeCodexCode' @@ -968,6 +973,7 @@ export async function createExecutionRuntimeHostComposition( now: Date.now, safeBoundaryResumeEnabled: process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME === '1', continuationFailpoint: dependencies.continuationFailpoint, + onContinuationLifecycleEvent: dependencies.onContinuationLifecycleEvent, generateSessionTitle: (input) => sessionEffectCoordinator.generateTitle(input), onSessionTitleChanged: (sessionId) => continuityCoordinator.enqueueCanonicalRefresh(sessionId), From e4fd853e93a83c9078df7a61f8b05d9faaa937ba Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:33:52 +0800 Subject: [PATCH 06/19] docs(runtime): record complete continuation test profile --- ...e-managed-workspace-continuation-crash-evidence-v1.zh-CN.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md index 52f06d0d24..064b57119f 100644 --- a/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md @@ -28,7 +28,7 @@ ## Production-shaped crash proof -三平台 gate 使用与当前源码匹配的 release Gitoxide helper,并完成下面的真实进程序列: +三平台 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; @@ -49,4 +49,3 @@ | Windows | 真实 helper、Runtime Host kill/reopen、0 次 provider replay | 证据由 `.github/workflows/gitoxide-helper-admission.yml` 的三平台 matrix 持续执行。CI 绿只证明这些已枚举边界;它不扩大到断电恢复、provider reattach 或 Desktop 自动续跑。 - From 06b268a747763b700f9f9d86ff07d490e68ccf42 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:40:57 +0800 Subject: [PATCH 07/19] test(runtime-host): expose continuation safety failures --- .../src/__tests__/fixtures/execution-host.ts | 3 + .../src/server/execution-composition.ts | 101 +++++++++++------- 2 files changed, 65 insertions(+), 39 deletions(-) diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts index b758558d2d..c34f9fda86 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts @@ -92,6 +92,9 @@ const result = await startExecutionRuntimeHostCandidate( onContinuationLifecycleEvent: continuationFailpoint ? (event) => console.error(`[test-continuation] ${JSON.stringify(event)}`) : undefined, + onContinuationSafetyError: continuationFailpoint + ? (error) => console.error(`[test-continuation-safety] ${inspect(error, { depth: null })}`) + : undefined, }), }, ); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index f002dc65f9..c07b107510 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -216,6 +216,8 @@ export interface ExecutionRuntimeHostCompositionDependencies { readonly onContinuationLifecycleEvent?: ( event: RuntimeContinuationLifecycleEvent, ) => void | Promise; + /** Production-shaped test diagnostic; correctness still fails closed. */ + readonly onContinuationSafetyError?: (error: unknown) => void; readonly oauthAuthorization?: Pick< HostOAuthCoordinatorInput, 'startCodexAuthorization' | 'pollCodexAuthorization' | 'exchangeCodexCode' @@ -996,45 +998,52 @@ export async function createExecutionRuntimeHostComposition( : {}), }); }, - inspectContinuationSafety: createLocalContinuationSafetyInspector({ - readSessionCwd: async (sessionId) => - (await stores.sessionStore.readHeaderSnapshot(sessionId)).cwd, - resolveWorkspaceIdentity: async (cwd) => resolveWorkspaceIdentity({ path: cwd }), - listAvailableToolNames: resolveAvailableToolNames, - hasPendingBackgroundOperations: async (sessionId) => { - const graph = requireGraphCoordinator(graphCoordinator); - const graphWake = requireGraphSupervisorWake(graphSupervisorWake); - const [resourcesLive, graphLive, descendantLive] = await Promise.all([ - runtimeResources!.hasLiveSessionResources(sessionId), - graph.hasLiveSessionState(sessionId), - hasLiveLinkedDescendantState( - requireSessionManager(manager), - stores.agentRunStore, - sessionId, - async (descendantSessionId) => - (await runtimeResources!.hasLiveSessionResources(descendantSessionId)) || - graph.hasLiveSessionState(descendantSessionId) || - graphWake.hasLiveSessionState(descendantSessionId), - ), - ]); - return ( - resourcesLive || graphLive || graphWake.hasLiveSessionState(sessionId) || descendantLive - ); - }, - readManagedWorkspaceBoundary: async (sessionId) => { - const header = await stores.sessionStore.readHeaderSnapshot(sessionId); - if (header.toolProfile !== 'managed-coding-v1') return undefined; - const runtime = requireGitoxideManagedMutationRuntime(gitoxideManagedMutationRuntime); - return inspectGitoxideManagedContinuationBoundary({ - storageRootLease: context.owner.lease, - sourceRoot: header.cwd, - sessionId, - invocationOwnerToken: runtime.invocationOwnerToken, - helperCapability: runtime.helperCapability, - settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), - }); - }, - }), + inspectContinuationSafety: observeContinuationSafetyErrors( + createLocalContinuationSafetyInspector({ + readSessionCwd: async (sessionId) => + (await stores.sessionStore.readHeaderSnapshot(sessionId)).cwd, + resolveWorkspaceIdentity: async (cwd) => resolveWorkspaceIdentity({ path: cwd }), + listAvailableToolNames: resolveAvailableToolNames, + hasPendingBackgroundOperations: async (sessionId) => { + const graph = requireGraphCoordinator(graphCoordinator); + const graphWake = requireGraphSupervisorWake(graphSupervisorWake); + const [resourcesLive, graphLive, descendantLive] = await Promise.all([ + runtimeResources!.hasLiveSessionResources(sessionId), + graph.hasLiveSessionState(sessionId), + hasLiveLinkedDescendantState( + requireSessionManager(manager), + stores.agentRunStore, + sessionId, + async (descendantSessionId) => + (await runtimeResources!.hasLiveSessionResources(descendantSessionId)) || + graph.hasLiveSessionState(descendantSessionId) || + graphWake.hasLiveSessionState(descendantSessionId), + ), + ]); + return ( + resourcesLive || + graphLive || + graphWake.hasLiveSessionState(sessionId) || + descendantLive + ); + }, + readManagedWorkspaceBoundary: async (sessionId) => { + const header = await stores.sessionStore.readHeaderSnapshot(sessionId); + if (header.toolProfile !== 'managed-coding-v1') return undefined; + const runtime = requireGitoxideManagedMutationRuntime(gitoxideManagedMutationRuntime); + return inspectGitoxideManagedContinuationBoundary({ + storageRootLease: context.owner.lease, + sourceRoot: header.cwd, + sessionId, + invocationOwnerToken: runtime.invocationOwnerToken, + helperCapability: runtime.helperCapability, + settlementAuthority: + requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), + }); + }, + }), + dependencies.onContinuationSafetyError, + ), runBackendActivation: (operation) => runtimePolicyActivation.runBackendActivation(operation), messageAuthority: runtimeAuthority, hostedAgentGraphExecution: { @@ -1914,6 +1923,20 @@ function requireSessionManager(manager: SessionManager | undefined): SessionMana return manager; } +function observeContinuationSafetyErrors( + inspectContinuationSafety: ReturnType, + onError: ((error: unknown) => void) | undefined, +): ReturnType { + return async (sessionId) => { + try { + return await inspectContinuationSafety(sessionId); + } catch (error) { + onError?.(error); + throw error; + } + }; +} + function requireGraphCoordinator( coordinator: AgentGraphCoordinator | undefined, ): AgentGraphCoordinator { From 87ce43e03287009ca59d3a9c75f56f1689d9d77a Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:48:09 +0800 Subject: [PATCH 08/19] fix(runtime-host): adopt workspace root before resume inspection --- packages/runtime-host/src/server/execution-composition.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index c07b107510..8ab72ab2e4 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -362,6 +362,9 @@ export async function createExecutionRuntimeHostComposition( const helperCapability = await resolvePackagedGitoxideHelperInternal({ invocationOwnerToken, }); + requireExecutionStoresWorkspaceMutationAuthorityInternal( + stores, + ).adoptRootForManagedExecution(); gitoxideManagedMutationRuntime = Object.freeze({ invocationOwnerToken, helperCapability, From 600615770d1198144a2ce59920a36b36232de85f Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:53:30 +0800 Subject: [PATCH 09/19] fix(storage): persist workspace continuation replay identity --- .../regenerate-root-admission.test.ts | 49 +++++++++++++++++++ packages/storage/src/agent-run-store.ts | 10 +++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/storage/src/__tests__/regenerate-root-admission.test.ts b/packages/storage/src/__tests__/regenerate-root-admission.test.ts index f1c2d0c501..c13d975226 100644 --- a/packages/storage/src/__tests__/regenerate-root-admission.test.ts +++ b/packages/storage/src/__tests__/regenerate-root-admission.test.ts @@ -106,6 +106,55 @@ test('new root admissions reject removed Automation authority', async () => { } }); +test('workspace-bound continuation admission preserves its replay manifest identity', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-workspace-continuation-admission-')); + const stores: Array> = []; + try { + const store = createSqliteAgentRunStore(root); + stores.push(store); + const execution = { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 3, + claimId: 'continuation-claim', + boundaryDigest: `sha256:${'a'.repeat(64)}`, + replayManifestDigest: `sha256:${'b'.repeat(64)}`, + providerReplayDigest: `sha256:${'c'.repeat(64)}`, + safetyDigest: `sha256:${'d'.repeat(64)}`, + targetInvocationId: 'target-invocation', + } as const; + const admitted = await store.admitRootTurn( + admissionInput({ + sessionId: 'continuation-session', + turnId: 'continuation-turn', + proposedRunId: 'continuation-run', + proposedUserMessageId: null, + execution, + normalizedInput: null, + }), + ); + assert.equal(admitted.kind, 'admitted'); + assert.deepEqual(admitted.admission.execution, execution); + store.close?.(); + stores.pop(); + + const reopened = createSqliteAgentRunStore(root); + stores.push(reopened); + assert.deepEqual( + (await reopened.readRootTurnAdmission('continuation-session', 'continuation-turn')) + ?.execution, + execution, + ); + reopened.close?.(); + stores.pop(); + } finally { + for (const store of stores) store.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + function admissionInput(overrides: Partial = {}): AdmitRootTurnInput { return { sessionId: 'root-session', diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 069635e85d..54d0ee1498 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -1959,7 +1959,7 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript }); } if (value.kind === 'safe_boundary_continuation') { - const keys = [ + const legacyKeys = [ 'kind', 'sourceInvocationId', 'sourceRunId', @@ -1971,6 +1971,10 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript 'safetyDigest', 'targetInvocationId', ]; + const hasReplayManifestDigest = Object.hasOwn(value, 'replayManifestDigest'); + const keys = hasReplayManifestDigest + ? [...legacyKeys, 'replayManifestDigest'] + : legacyKeys; if ( !hasExactKeys(value, keys) || typeof value.sourceInvocationId !== 'string' || @@ -1984,6 +1988,7 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript typeof value.claimId !== 'string' || !isSafeId(value.claimId) || !isSha256Digest(value.boundaryDigest) || + (hasReplayManifestDigest && !isSha256Digest(value.replayManifestDigest)) || !isSha256Digest(value.providerReplayDigest) || !isSha256Digest(value.safetyDigest) || typeof value.targetInvocationId !== 'string' || @@ -1999,6 +2004,9 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript sourceRuntimeEventHighWater: value.sourceRuntimeEventHighWater as number, claimId: value.claimId, boundaryDigest: value.boundaryDigest, + ...(hasReplayManifestDigest + ? { replayManifestDigest: value.replayManifestDigest as `sha256:${string}` } + : {}), providerReplayDigest: value.providerReplayDigest, safetyDigest: value.safetyDigest, targetInvocationId: value.targetInvocationId, From 3fb042e27d9046084739b63d146cd080c0381690 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 09:56:48 +0800 Subject: [PATCH 10/19] fix(runtime-host): validate workspace continuation replay proof --- .../runtime-host/src/server/hosted-execution-projection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/server/hosted-execution-projection.ts b/packages/runtime-host/src/server/hosted-execution-projection.ts index f5f50f82cd..77dd5b8a90 100644 --- a/packages/runtime-host/src/server/hosted-execution-projection.ts +++ b/packages/runtime-host/src/server/hosted-execution-projection.ts @@ -73,7 +73,8 @@ export class HostedExecutionProjectionReader { !start || start.claimId !== execution.claimId || start.boundaryDigest !== execution.boundaryDigest || - start.replayManifestDigest !== execution.boundaryDigest || + start.replayManifestDigest !== + (execution.replayManifestDigest ?? execution.boundaryDigest) || start.providerReplayDigest !== execution.providerReplayDigest || start.immediateSource.sessionId !== run.sessionId || start.immediateSource.invocationId !== execution.sourceInvocationId || From caefc70f16f1278f7343f072903a3152a9b69399 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:01:44 +0800 Subject: [PATCH 11/19] test(runtime-host): report recovered continuation state --- .../__tests__/gitoxide-managed-continuation-crash.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts index 2adce876f9..ab58cd5f5c 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -119,7 +119,11 @@ test('a started workspace-bound continuation survives Host death without provide turnId: targetTurnId, }); assert.equal(target.runId, admission.runId); - assert.equal(target.status === 'created' || target.status === 'running', true); + assert.equal( + target.status === 'created' || target.status === 'running', + true, + JSON.stringify(target), + ); assert.deepEqual(await successorClient.queryTurnResume({ sessionId: fixture.sessionId }), { sessionId: fixture.sessionId, disposition: 'parked', From aa77a7a61d06e1f00cfbeeb0ddf893fbe9bf599b Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:05:40 +0800 Subject: [PATCH 12/19] test(runtime-host): assert crashed continuation closure --- ...itoxide-managed-continuation-crash.test.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts index ab58cd5f5c..cc2eaa5ffa 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -119,16 +119,31 @@ test('a started workspace-bound continuation survives Host death without provide turnId: targetTurnId, }); assert.equal(target.runId, admission.runId); - assert.equal( - target.status === 'created' || target.status === 'running', - true, - JSON.stringify(target), - ); - assert.deepEqual(await successorClient.queryTurnResume({ sessionId: fixture.sessionId }), { + assert.equal(target.status, 'failed'); + if (target.status !== 'failed') assert.fail('Crashed continuation Run was not closed'); + assert.equal(target.failureClass, 'app_restarted'); + const plan = { sessionId: fixture.sessionId, - disposition: 'parked', - reason: 'continuation_started_indeterminate', - }); + disposition: 'parked' as const, + reason: 'continuation_started_indeterminate' as const, + }; + assert.deepEqual( + await successorClient.queryTurnResume({ + sessionId: fixture.sessionId, + sourceRunId: source.sourceRunId, + expectedRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + }), + plan, + ); + assert.deepEqual( + await successorClient.startTurnResume({ + sessionId: fixture.sessionId, + turnId: targetTurnId, + sourceRunId: source.sourceRunId, + sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + }), + { kind: 'parked', plan }, + ); assert.equal(await readFile(callLog, 'utf8'), ''); } finally { await successorClient.close(); From 12b8c4fbe62df7f4dd65e54b46d5cbcb4cba09b6 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:10:08 +0800 Subject: [PATCH 13/19] fix(runtime): preserve indeterminate continuation after restart --- packages/runtime/src/runtime-resume.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index c9255c39c5..80f4106c25 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -624,6 +624,17 @@ export class RuntimeContinuationPlanner { isTerminalRunStatus(targetRun.status) && terminalRunHeaderMatchesFact(targetRun, terminalClassification.fact) ) { + if ( + state.startKind === 'runtime_admission' && + terminalClassification.fact.runStatus === 'failed' && + terminalClassification.fact.failureClass === 'app_restarted' + ) { + return parkedPlan( + 'continuation_started_indeterminate', + 'continuation-start is durable and Host restart closure does not prove provider absence', + detail, + ); + } return parkedPlan( 'continuation_already_exists', 'source boundary already has a terminal continuation', From f2739a125f7efb6326aed23e82df6b2706760636 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:15:11 +0800 Subject: [PATCH 14/19] test(runtime-host): retry parked continuation on new turn --- .../runtime-host/src/__tests__/fixtures/execution-host.ts | 6 ------ .../__tests__/gitoxide-managed-continuation-crash.test.ts | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts index c34f9fda86..6eed6ccabb 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts @@ -89,12 +89,6 @@ const result = await startExecutionRuntimeHostCandidate( await new Promise(() => undefined); } : undefined, - onContinuationLifecycleEvent: continuationFailpoint - ? (event) => console.error(`[test-continuation] ${JSON.stringify(event)}`) - : undefined, - onContinuationSafetyError: continuationFailpoint - ? (error) => console.error(`[test-continuation-safety] ${inspect(error, { depth: null })}`) - : undefined, }), }, ); diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts index cc2eaa5ffa..c85be1d759 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -138,7 +138,7 @@ test('a started workspace-bound continuation survives Host death without provide assert.deepEqual( await successorClient.startTurnResume({ sessionId: fixture.sessionId, - turnId: targetTurnId, + turnId: `${targetTurnId}-retry`, sourceRunId: source.sourceRunId, sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, }), From 360076faf3c25e84cb9b45c1ee6a3d9f4bf4afc0 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 10:20:16 +0800 Subject: [PATCH 15/19] docs(runtime): record continuation restart closure semantics --- ...aged-workspace-continuation-crash-evidence-v1.zh-CN.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md index 064b57119f..3ba7e40e96 100644 --- a/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md @@ -12,6 +12,8 @@ - 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 是否执行。 @@ -21,6 +23,7 @@ | --- | --- | | 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 | @@ -35,8 +38,9 @@ 3. 启动真实 Runtime Host,提交 workspace-bound claim 与 durable start; 4. 在 `after_continuation_start_committed` 杀死整个 Host 进程; 5. 使用同一 storage root 与 helper 启动新 Host; -6. 验证 claim 仍绑定原 commit/tree/revision,恢复结果为 `continuation_started_indeterminate`; -7. 验证 provider invocation log 始终为空。 +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 始终为空。 这项测试有意不声称可以恢复 provider 的网络执行。它证明的是“未知时不重发”,不是 bit-exact provider continuation。 From 3932693ccc106c8e4d31c08937c2f77f749117d0 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:47:13 +0800 Subject: [PATCH 16/19] fix(runtime-host): retain continuation root ownership --- .../src/__tests__/gitoxide-managed-continuation-crash.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts index c85be1d759..28af65d986 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -222,14 +222,14 @@ async function withManagedContinuationFixture( sessionId = session.id; const helper = await admitRealHelper(helperInputPath); await openGitoxideManagedMutationSession({ - storageRoot: capability.canonicalPath, + storageRootLease: owner.lease, sourceRoot: root, sessionId, ...helper, settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), }); const observedBoundary = await inspectGitoxideManagedContinuationBoundary({ - storageRoot: capability.canonicalPath, + storageRootLease: owner.lease, sourceRoot: root, sessionId, ...helper, From 0e88cddf0520ae4b16e9335c099f292654e79118 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 13:49:50 +0800 Subject: [PATCH 17/19] test(runtime-host): prove provider-once continuation recovery --- ...ce-continuation-crash-evidence-v1.zh-CN.md | 6 + .../fixtures/execution-host-suite.ts | 6 + .../src/__tests__/fixtures/execution-host.ts | 5 + ...itoxide-managed-continuation-crash.test.ts | 194 +++++++++++++++++- 4 files changed, 209 insertions(+), 2 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md index 3ba7e40e96..5af9381816 100644 --- a/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-continuation-crash-evidence-v1.zh-CN.md @@ -42,6 +42,12 @@ 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。 ## 平台能力矩阵 diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 786b0acddb..41711cc1c2 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -124,6 +124,7 @@ export interface ExecutionHostTestOptions { | 'after_continuation_claim_committed' | 'after_run_created' | 'after_continuation_start_committed'; + readonly providerFailpointAfterSend?: boolean; } export interface TurnLedger { @@ -1035,6 +1036,11 @@ export class ExecutionFixture { } 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), [ diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts index 6eed6ccabb..016bb363f6 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts @@ -58,12 +58,17 @@ if (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[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(() => undefined); + } yield* super.send(input); } } diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts index 28af65d986..4d0d189bd0 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -36,6 +36,8 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { createSqliteRuntimeStore } from '@maka/storage'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; import { requireExecutionStoresWorkspaceMutationAuthorityInternal } from '@maka/storage/execution-stores-workspace-authority-internal'; @@ -170,6 +172,82 @@ test('a started workspace-bound continuation survives Host death without provide ); }); +test('a revision-two continuation never calls the provider twice after Host death', async (t) => { + const helperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + const bundledNpmResourcesRoot = process.env.MAKA_BUNDLED_NPM_RESOURCES_ROOT; + if (!helperPath || !bundledNpmResourcesRoot) { + t.skip( + 'MAKA_GITOXIDE_HELPER_PATH and MAKA_BUNDLED_NPM_RESOURCES_ROOT are required for the real provider crash test', + ); + return; + } + + await withManagedContinuationFixture( + helperPath, + bundledNpmResourcesRoot, + async ({ fixture, resourcesRoot, callLog, boundary }) => { + assert.equal(boundary.revision, 2); + const source = await fixture.seedSafeBoundaryContinuationSource(); + const crashHost = await fixture.startHost(undefined, true, { + packagedResourcesRoot: resourcesRoot, + providerCallLogPath: callLog, + providerFailpointAfterSend: true, + }); + const crashClient = await connectClient(fixture.root); + const targetTurnId = 'turn-workspace-bound-provider-crash'; + try { + const initialPlan = await crashClient.queryTurnResume({ sessionId: fixture.sessionId }); + assert.equal(initialPlan.disposition, 'ready', JSON.stringify(initialPlan)); + const failpoint = waitForProviderFailpoint(crashHost.child); + const start = crashClient + .startTurnResume({ + sessionId: fixture.sessionId, + turnId: targetTurnId, + sourceRunId: source.sourceRunId, + sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + }) + .then( + () => undefined, + () => undefined, + ); + await failpoint; + assert.equal(await providerCallCount(callLog), 1); + await fixture.killHost(crashHost); + await withTimeout(start, PROCESS_TIMEOUT_MS, 'provider-crashed continuation did not close'); + } finally { + await crashClient.close().catch(() => undefined); + } + + const successorHost = await fixture.startHost(undefined, true, { + packagedResourcesRoot: resourcesRoot, + providerCallLogPath: callLog, + }); + const successorClient = await connectClient(fixture.root); + try { + const plan = await successorClient.queryTurnResume({ + sessionId: fixture.sessionId, + sourceRunId: source.sourceRunId, + expectedRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + }); + assert.equal(plan.disposition, 'parked'); + assert.equal(plan.reason, 'continuation_started_indeterminate'); + const retry = await successorClient.startTurnResume({ + sessionId: fixture.sessionId, + turnId: `${targetTurnId}-retry`, + sourceRunId: source.sourceRunId, + sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + }); + assert.equal(retry.kind, 'parked'); + assert.equal(await providerCallCount(callLog), 1); + } finally { + await successorClient.close(); + await fixture.stopHost(successorHost); + } + }, + { advanceWorkspace: true }, + ); +}); + async function withManagedContinuationFixture( helperInputPath: string, bundledNpmResourcesRoot: string, @@ -179,6 +257,7 @@ async function withManagedContinuationFixture( callLog: string; boundary: NonNullable>>; }) => Promise, + options: { readonly advanceWorkspace?: boolean } = {}, ): Promise { const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-continuation-'))); const root = join(base, 'root'); @@ -221,13 +300,19 @@ async function withManagedContinuationFixture( }); sessionId = session.id; const helper = await admitRealHelper(helperInputPath); - await openGitoxideManagedMutationSession({ + const sessionInput = { storageRootLease: owner.lease, sourceRoot: root, sessionId, ...helper, settlementAuthority: requireExecutionStoresWorkspaceMutationAuthorityInternal(stores), - }); + }; + const managedSession = await openGitoxideManagedMutationSession(sessionInput); + if (options.advanceWorkspace) { + await prepareManagedWriteT1({ stores, session: managedSession, sessionId }); + const recovered = await openGitoxideManagedMutationSession(sessionInput); + assert.equal(recovered.head.revision, 2); + } const observedBoundary = await inspectGitoxideManagedContinuationBoundary({ storageRootLease: owner.lease, sourceRoot: root, @@ -250,6 +335,70 @@ async function withManagedContinuationFixture( } } +async function prepareManagedWriteT1(input: { + readonly stores: Awaited>; + readonly session: Awaited>; + readonly sessionId: string; +}): Promise { + const operationId = 'operation-continuation-successor-setup'; + const toolCallId = `${operationId}-call`; + const args = { path: 'notes.txt', content: 'successor\n' }; + const admission = await input.session.admitManagedMutation({ + operationId, + toolName: 'Write', + persistedArgs: args, + abortSignal: new AbortController().signal, + }); + const identity = { + sessionId: input.sessionId, + invocationId: 'invocation-continuation-successor-setup', + runId: 'run-continuation-successor-setup', + turnId: 'turn-continuation-successor-setup', + }; + const canonicalArgsHash = canonicalToolArgsHash('Write', args); + const callEvent: RuntimeEvent = { + id: `${operationId}_call`, + ...identity, + ts: 10, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: toolCallId, name: 'Write', args }, + refs: { operationId, toolCallId }, + }; + const dispatchEvent: RuntimeEvent = { + id: `${operationId}_dispatch`, + ...identity, + ts: 10, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile', + managedMutation: admission.durableDispatch, + }, + }, + refs: { operationId, toolCallId }, + }; + await input.stores.runtimeEventStore.commitToolPrepared({ + operationId, + journalEventId: `${operationId}_prepared`, + runtimeEvent: callEvent, + dispatchRuntimeEvent: dispatchEvent, + providerToolCallId: toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile', + committedAt: 10, + }); +} + async function preparePackagedResources( base: string, helperInputPath: string, @@ -342,6 +491,47 @@ function waitForContinuationFailpoint(child: ChildProcess): Promise { ); } +function waitForProviderFailpoint(child: ChildProcess): Promise { + return waitForChildMessage(child, 'test.provider_failpoint', 'provider'); +} + +function waitForChildMessage( + child: ChildProcess, + expectedType: string, + label: string, +): Promise { + return withTimeout( + new Promise((resolve, reject) => { + const onMessage = (message: unknown): void => { + if ( + message && + typeof message === 'object' && + (message as { type?: unknown }).type === expectedType + ) { + cleanup(); + resolve(); + } + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + cleanup(); + reject(new Error(`Runtime Host exited before ${label} failpoint: ${code ?? signal}`)); + }; + const cleanup = (): void => { + child.off('message', onMessage); + child.off('exit', onExit); + }; + child.on('message', onMessage); + child.on('exit', onExit); + }), + PROCESS_TIMEOUT_MS * 3, + `Runtime Host did not reach the ${label} failpoint`, + ); +} + +async function providerCallCount(path: string): Promise { + return (await readFile(path, 'utf8')).split(/\r?\n/u).filter(Boolean).length; +} + function git(cwd: string, args: readonly string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); } From c0ae9f205945d05178ddc21f7fce8fcef679d4f0 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 14:30:23 +0800 Subject: [PATCH 18/19] ci(gitoxide): use supported managed npm Node --- .../workflows/gitoxide-helper-admission.yml | 9 ++++-- .../gitoxide-helper-workflow-policy.test.mjs | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 scripts/gitoxide-helper-workflow-policy.test.mjs diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index 006bd45370..fb88fd7faa 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -35,6 +35,7 @@ on: - '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' @@ -58,6 +59,7 @@ on: - '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' @@ -85,7 +87,8 @@ 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: Check Rust formatting working-directory: native/gitoxide-helper @@ -102,7 +105,9 @@ jobs: 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 && diff --git a/scripts/gitoxide-helper-workflow-policy.test.mjs b/scripts/gitoxide-helper-workflow-policy.test.mjs new file mode 100644 index 0000000000..b49709788d --- /dev/null +++ b/scripts/gitoxide-helper-workflow-policy.test.mjs @@ -0,0 +1,32 @@ +/* + * 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 { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +const workflowPath = join(import.meta.dirname, '../.github/workflows/gitoxide-helper-admission.yml'); + +test('runs the packaged managed-npm contract on its minimum supported Node 22 runtime', () => { + const workflow = readFileSync(workflowPath, 'utf8'); + + assert.match(workflow, /node-version: ['"]?22\.22\.2['"]?/u); + assert.doesNotMatch(workflow, /node-version: ['"]?22\.19\.0['"]?/u); +}); From db5a9d4158f868b938fcb78bfd347b0853c6d658 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 14:47:04 +0800 Subject: [PATCH 19/19] ci(runtime-host): provision managed continuation sandboxes --- .../workflows/gitoxide-helper-admission.yml | 19 ++++++++++++++ ...itoxide-managed-continuation-crash.test.ts | 26 +++++++++++++++++++ .../gitoxide-helper-workflow-policy.test.mjs | 14 +++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index fb88fd7faa..8de1f9ea52 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -31,6 +31,7 @@ on: - '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' @@ -55,6 +56,7 @@ on: - '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' @@ -90,6 +92,22 @@ jobs: # 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 @@ -118,6 +136,7 @@ jobs: 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 diff --git a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts index 4d0d189bd0..aac24e0f72 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-managed-continuation-crash.test.ts @@ -66,10 +66,12 @@ test('a started workspace-bound continuation survives Host death without provide ); return; } + const windowsSandboxPath = requireWindowsSandboxPath(); await withManagedContinuationFixture( helperPath, bundledNpmResourcesRoot, + windowsSandboxPath, async ({ fixture, resourcesRoot, callLog, boundary }) => { const source = await fixture.seedSafeBoundaryContinuationSource(); const crashHost = await fixture.startHost(undefined, true, { @@ -181,10 +183,12 @@ test('a revision-two continuation never calls the provider twice after Host deat ); return; } + const windowsSandboxPath = requireWindowsSandboxPath(); await withManagedContinuationFixture( helperPath, bundledNpmResourcesRoot, + windowsSandboxPath, async ({ fixture, resourcesRoot, callLog, boundary }) => { assert.equal(boundary.revision, 2); const source = await fixture.seedSafeBoundaryContinuationSource(); @@ -251,6 +255,7 @@ test('a revision-two continuation never calls the provider twice after Host deat async function withManagedContinuationFixture( helperInputPath: string, bundledNpmResourcesRoot: string, + windowsSandboxInputPath: string | undefined, run: (input: { fixture: ExecutionFixture; resourcesRoot: string; @@ -282,6 +287,7 @@ async function withManagedContinuationFixture( base, helperInputPath, bundledNpmResourcesRoot, + windowsSandboxInputPath, ); const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); @@ -403,6 +409,7 @@ async function preparePackagedResources( base: string, helperInputPath: string, bundledNpmResourcesInputRoot: string, + windowsSandboxInputPath: string | undefined, ): Promise { const resourcesRoot = join(base, 'resources'); const helperDirectory = join(resourcesRoot, 'gitoxide'); @@ -438,9 +445,28 @@ async function preparePackagedResources( recursive: true, verbatimSymlinks: true, }); + if (windowsSandboxInputPath) { + const sandboxDirectory = join(resourcesRoot, 'windows-sandbox'); + await mkdir(sandboxDirectory, { recursive: true }); + await copyFile( + await realpath(windowsSandboxInputPath), + join(sandboxDirectory, 'maka-windows-sandbox.exe'), + ); + } return resourcesRoot; } +function requireWindowsSandboxPath(): string | undefined { + if (process.platform !== 'win32') return undefined; + const sandboxPath = process.env.MAKA_WINDOWS_SANDBOX_PATH; + if (!sandboxPath) { + throw new Error( + 'MAKA_WINDOWS_SANDBOX_PATH is required for the production-shaped Windows continuation test', + ); + } + return sandboxPath; +} + async function admitRealHelper(helperInputPath: string) { const executablePath = await realpath(helperInputPath); const [bytes, info] = await Promise.all([readFile(executablePath), stat(executablePath)]); diff --git a/scripts/gitoxide-helper-workflow-policy.test.mjs b/scripts/gitoxide-helper-workflow-policy.test.mjs index b49709788d..f1935dbe56 100644 --- a/scripts/gitoxide-helper-workflow-policy.test.mjs +++ b/scripts/gitoxide-helper-workflow-policy.test.mjs @@ -22,7 +22,10 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { test } from 'node:test'; -const workflowPath = join(import.meta.dirname, '../.github/workflows/gitoxide-helper-admission.yml'); +const workflowPath = join( + import.meta.dirname, + '../.github/workflows/gitoxide-helper-admission.yml', +); test('runs the packaged managed-npm contract on its minimum supported Node 22 runtime', () => { const workflow = readFileSync(workflowPath, 'utf8'); @@ -30,3 +33,12 @@ test('runs the packaged managed-npm contract on its minimum supported Node 22 ru assert.match(workflow, /node-version: ['"]?22\.22\.2['"]?/u); assert.doesNotMatch(workflow, /node-version: ['"]?22\.19\.0['"]?/u); }); + +test('provisions each production filesystem sandbox before the managed continuation crash test', () => { + const workflow = readFileSync(workflowPath, 'utf8'); + + assert.match(workflow, /sudo apt-get update && sudo apt-get install -y bubblewrap/u); + assert.match(workflow, /apparmor_restrict_unprivileged_userns/u); + assert.match(workflow, /experiments\/windows-sandbox\/launcher[\s\S]*cargo build --locked/u); + assert.match(workflow, /MAKA_WINDOWS_SANDBOX_PATH:/u); +});