From c7f495cc0ffcf12c26d9d6a75de8b131e00509d7 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Tue, 18 Aug 2026 20:19:46 +0800 Subject: [PATCH 1/9] feat(storage): settle managed workspace mutations --- ...ed-workspace-m2-extraction-ledger.zh-CN.md | 17 + ...e-mutation-execution-admission-v1.zh-CN.md | 16 +- ...workspace-mutation-reservation-v1.zh-CN.md | 9 +- packages/core/src/runtime-event.ts | 56 +- .../runtime-event-read-model.test.ts | 25 + .../tool-runtime-durable-boundary.test.ts | 86 +-- packages/runtime/src/conversation-copy.ts | 24 +- .../runtime/src/runtime-event-read-model.ts | 7 + packages/runtime/src/tool-runtime.ts | 53 +- .../git-workspace-service-crash-child.ts | 145 +++++ .../__tests__/managed-workspace-owner.test.ts | 604 ++++++++++++++++- ...d-workspace-worker-bridge-internal.test.ts | 4 + ...pace-version-authority-persistence.test.ts | 37 +- ...ion-stores-workspace-authority-internal.ts | 21 + packages/storage/src/execution-stores.ts | 2 + packages/storage/src/git-workspace-service.ts | 246 ++++++- ...ged-baseline-receipt-authority-internal.ts | 4 + ...d-mutation-candidate-authority-internal.ts | 8 + ...-workspace-execution-authority-internal.ts | 31 +- .../storage/src/managed-workspace-owner.ts | 605 +++++++++++++++++- ...anaged-workspace-worker-bridge-internal.ts | 132 +++- .../storage/src/runtime-event-authority.ts | 5 + packages/storage/src/sqlite-runtime-store.ts | 255 +++++++- .../workspace-version-authority-internal.ts | 27 + scripts/recovery-test-inventory.mjs | 8 +- 25 files changed, 2281 insertions(+), 146 deletions(-) create mode 100644 packages/storage/src/execution-stores-workspace-authority-internal.ts diff --git a/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md b/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md index 34e40b164c..9c6ad60949 100644 --- a/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md @@ -75,6 +75,23 @@ M2.3b 不迁入 `ManagedWorkspaceOwner` mutation lease/profile、built-in Write/ Git candidate capture/discard、successor/error bundle composition 或 Desktop/CLI wiring;这些共同构成 M2.4 的首个 生产消费者。只读 worker 的存在不能签发 mutation profile,caller callback 也不能自证执行能力。 +## M2.4 增量归属 + +| 路径 | 主要不变量 | +|---|---| +| `packages/core/src/session.ts` | 显式 `managed-coding-v1` profile;attached profile 不静默升级 | +| `packages/runtime/src/tool-runtime.ts` | Runtime-owned result snapshot;managed terminal 只采用 exact durable proof | +| `packages/storage/src/managed-workspace-owner.ts` | 同一 execution handle 绑定 head/path/worker profile/candidate/settlement | +| `packages/storage/src/sqlite-runtime-store.ts` | successor 或 no-effect terminal 的专用原子 writer;generic T2 继续被拒绝 | +| `packages/storage/src/git-workspace-service.ts` | candidate capture/accept 与成功 no-op 的明确区分 | +| `packages/runtime-host/src/server/managed-workspace-mutation-session.ts` | production Host 将 Read/Glob/Grep/Write/Edit 路由到同一 owner-bound workspace | +| `packages/runtime-host/src/server/execution-composition.ts` | 仅显式 managed profile 取得 managed admission + mutation worker | +| `scripts/recovery-test-inventory.mjs` | successor SQLite commit 后真实 process kill/reopen 的三平台证据 | +| `runtime-managed-workspace-write-edit-production-v1.zh-CN.md` | M2.4 owner、原子边界、失败状态和平台矩阵 | + +M2.4 不绑定 continuation cursor,也不在 attached checkout 自动 redo。前者属于 M3,restore/rebaseline/publish +属于 M4。 + ## Commit 映射 | 当前提交 | 来源 | 说明 | diff --git a/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md index bd756bb7e2..b17c508c84 100644 --- a/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md @@ -1,7 +1,7 @@ # Managed Workspace Mutation Runtime Settlement v1 - 阶段:M2.3b -- 状态:实现切片;保持 Draft,等待 M2.4 Write/Edit 生产消费者 +- 状态:实现切片;M2.4 已提供首个显式 managed Write/Edit 生产消费者 - owner:Tool Runtime managed settlement seam - durable 真相:M2.3a SQLite reservation 与 owner 已提交的 immutable outcome @@ -19,7 +19,7 @@ execution。 Runtime 只接受三种 owner 结算: 1. `workspace_successor_committed`:M2.1 的成功 T2、successor 与 head 已经原子提交; -2. `safely_discarded`:owner 已证明 candidate 未被接受,并已提交 exact error outcome; +2. `safely_discarded`:owner 已证明 workspace 无副作用,并已提交 exact error/no-op outcome 与 terminal fact; 3. `unsettled`:副作用或结算状态不可证明,M2.3a reservation 保留给恢复流程。 owner 返回值首先经过运行时结构校验,规范化成内部 terminal union。managed/generic lane 只由 T1 前已经确定的 @@ -72,9 +72,9 @@ flowchart TD E --> R ``` -`safely_discarded` 只携带一个 exact `providerResult`。Runtime 从该值生成 canonical content,并执行与普通工具 -相同的 `maxResultBytes` 检查。getter、serialization、canonicalization 或 size-check 的任何失败都变成 managed -unsettled,不写 generic T2。 +`safely_discarded` 与 success 一样只携带 terminal proof 和 exact `durableOutcome`,不允许 Host 重新提交 +`providerResult`。Runtime 在 operation 边界持有唯一 bounded strict-JSON snapshot;getter、serialization、 +canonicalization 或 size-check 的任何失败都变成 managed unsettled,不写 generic T2。 `workspace_successor_committed` 只携带 `durableOutcome`,不得重新提交 provider value。Runtime 在调用真实 operation 完成边界用一次 bounded strict-JSON walker 同时执行 byte budget、类型校验、plain snapshot 和递归冻结。walker @@ -110,7 +110,7 @@ parent refs 和 duration。缺字段、多字段或任意值不同都 fail-stop | T1 reservation kill/reopen | CI 证明 | CI 证明 | CI 证明 | | 跨进程唯一 mutation reservation | CI 证明 | CI 证明 | CI 证明 | | Runtime managed settlement fail-stop | 平台无关测试 | 平台无关测试 | 平台无关测试 | -| mutation worker/profile/candidate | M2.4 | M2.4 | M2.4 | +| mutation worker/profile/candidate | CI 证明 | CI 证明 | CI 证明 | | power-loss convergence | 不在本切片 | 不在本切片 | 不在本切片 | 统一 recovery inventory 同时包含 managed baseline/candidate、`sqlite-runtime-crash` 与 @@ -139,5 +139,5 @@ parent refs 和 duration。缺字段、多字段或任意值不同都 fail-stop - 不 capture、discard 或 accept Git candidate; - 不接 Desktop/CLI,不改变当前用户可见 resume 能力。 -M2.3b 没有生产消费者,因此即使测试通过也保持 Draft。M2.4 必须由同一个 owner 同时签发并执行真实 mutation -profile,不能重新引入 caller digest、静态标签或 callback 自证。 +M2.4 现在由同一个 owner 同时签发并执行真实 mutation profile,详细合同见 +[Managed Workspace Write/Edit Production Composition v1](./runtime-managed-workspace-write-edit-production-v1.zh-CN.md)。 diff --git a/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md index 3560f840d3..625537e90d 100644 --- a/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md @@ -1,7 +1,7 @@ # Managed Workspace Mutation Reservation v1 - 阶段:M2.3a -- 状态:实现切片;保持 Draft,等待 M2.3b admission 与 M2.4 Write/Edit 生产消费者 +- 状态:实现切片;M2.3b admission 与 M2.4 显式 managed Write/Edit 消费者已接入 - owner:SQLite workspace mutation authority ## 1. 主要不变量 @@ -73,8 +73,9 @@ successor,不重复推进 head。 | T1 后进程崩溃 | reservation 跨进程保留;新的 mutation 不能取得所有权 | | operation park | reservation 保留,禁止另一个 mutation 越过未知副作用 | -`safely_discarded` 的 canonical release fact 不属于本切片;M2.4 在拥有真实 candidate 与 Write/Edit 结果后 -定义。M2.3a 不提供手工删除 reservation 的公共 API。 +`safely_discarded` 的 canonical release fact 由 M2.4 定义为 `managed_mutation_terminal_v1`:只有 Git owner +证明 worktree 仍等于 exact T1 base 后,专用 SQLite writer 才能将 exact T2、terminal fact 与 reservation release +原子提交。M2.3a 仍不提供手工删除 reservation 的公共 API。 ## 5. 路径合同 @@ -93,7 +94,7 @@ M2.3b/M2.4 的平台 owner 在副作用前验证。Core 不读取 `process.platf | 多进程同 workspace 唯一 reservation | 承诺 | 承诺 | 承诺 | | process-crash 后 reservation 重建 | 承诺 | 承诺 | 承诺 | | path fact 跨平台同值同义 | 承诺 | 承诺 | 承诺 | -| filesystem mutation/candidate correctness | M2.4 | M2.4 | M2.4 | +| filesystem mutation/candidate correctness | CI 证明 | CI 证明 | CI 证明 | 本切片不承诺断电后的 Git/filesystem 收敛,也不接 Desktop/CLI。 diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index ed2ba8ee28..38215e1a89 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -255,6 +255,22 @@ export interface RuntimeEventManagedWorkspaceMutationV1 { executionProfileDigest: `sha256:${string}`; } +/** + * Immutable proof that a managed mutation reached a terminal no-effect state. + * The dedicated SQLite workspace writer is the only authority allowed to + * persist this fact together with the exact tool response and reservation + * release. + */ +export interface RuntimeEventManagedWorkspaceMutationTerminalV1 { + protocol: 'managed_mutation_terminal_v1'; + disposition: 'safely_discarded'; + reason: 'operation_failed_no_effect' | 'no_workspace_change'; + operationId: string; + dispatchEventId: string; + outcomeEventId: string; + mutation: RuntimeEventManagedWorkspaceMutationV1; +} + export interface RuntimeEventProtocolMarker { toolBoundary: ToolBoundaryProtocol; } @@ -330,6 +346,8 @@ export interface RuntimeEventActions { continuationStart?: RuntimeEventContinuationStartV2; /** Reserved workspace authority fact; only its atomic SQLite writer may persist it. */ workspaceFact?: RuntimeEventWorkspaceFactEnvelope; + /** Reserved terminal no-effect fact for an exact managed mutation T1. */ + managedMutationTerminal?: RuntimeEventManagedWorkspaceMutationTerminalV1; } // ============================================================================ @@ -517,6 +535,7 @@ const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( 'runtimeProtocol', 'continuationStart', 'workspaceFact', + 'managedMutationTerminal', ], ); const ANSWER_ACCEPTED_IDENTITY_SHAPE = defineObjectShape()( @@ -560,6 +579,19 @@ const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = ], [], ); +const RUNTIME_MANAGED_WORKSPACE_MUTATION_TERMINAL_SHAPE = + defineObjectShape()( + [ + 'protocol', + 'disposition', + 'reason', + 'operationId', + 'dispatchEventId', + 'outcomeEventId', + 'mutation', + ], + [], + ); const RUNTIME_PROTOCOL_MARKER_SHAPE = defineObjectShape()( ['toolBoundary'], [], @@ -767,7 +799,10 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { (value.runtimeProtocol === undefined || isRuntimeProtocolMarker(value.runtimeProtocol)) && (value.continuationStart === undefined || isRuntimeContinuationStart(value.continuationStart)) && - (value.workspaceFact === undefined || isRuntimeEventWorkspaceFactEnvelope(value.workspaceFact)) + (value.workspaceFact === undefined || + isRuntimeEventWorkspaceFactEnvelope(value.workspaceFact)) && + (value.managedMutationTerminal === undefined || + isRuntimeManagedWorkspaceMutationTerminal(value.managedMutationTerminal)) ); } @@ -871,6 +906,25 @@ function isRuntimeManagedWorkspaceMutation( ); } +export function isRuntimeManagedWorkspaceMutationTerminal( + value: unknown, +): value is RuntimeEventManagedWorkspaceMutationTerminalV1 { + return ( + isRecord(value) && + hasExactShape(value, RUNTIME_MANAGED_WORKSPACE_MUTATION_TERMINAL_SHAPE) && + value.protocol === 'managed_mutation_terminal_v1' && + value.disposition === 'safely_discarded' && + (value.reason === 'operation_failed_no_effect' || value.reason === 'no_workspace_change') && + typeof value.operationId === 'string' && + value.operationId.length > 0 && + typeof value.dispatchEventId === 'string' && + value.dispatchEventId.length > 0 && + typeof value.outcomeEventId === 'string' && + value.outcomeEventId.length > 0 && + isRuntimeManagedWorkspaceMutation(value.mutation) + ); +} + /** Platform-independent canonical Git path syntax used by durable mutation facts. */ export function isCanonicalManagedMutationPathV1(path: unknown): path is string { if ( diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 0dc9608aee..aded6db08c 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1686,6 +1686,31 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { }, }, }, + managedMutationTerminal: { + action: { + protocol: 'managed_mutation_terminal_v1', + disposition: 'safely_discarded', + reason: 'operation_failed_no_effect', + operationId: 'coverage-op', + dispatchEventId: 'coverage-dispatch', + outcomeEventId: 'coverage-outcome', + mutation: { + protocol: 'managed_mutation_v1', + repositoryId: `repository_${'1'.repeat(32)}`, + workspaceId: `workspace_${'2'.repeat(32)}`, + workspaceEpochId: `epoch_${'3'.repeat(32)}`, + workspaceInstanceId: `instance_${'4'.repeat(32)}`, + objectFormat: 'sha1', + baseWorkspaceVersionId: `version_${'5'.repeat(32)}`, + baseAcceptedEventId: 'coverage-base', + baseHeadRevision: 1, + baseCommitOid: '1'.repeat(40), + baseTreeOid: '2'.repeat(40), + expectedPaths: ['tracked.txt'], + executionProfileDigest: `sha256:${'6'.repeat(64)}`, + }, + }, + }, runtimeProtocol: { action: { toolBoundary: 't1_after_preflight_v1' } }, }; diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 269bc46b7b..f3e1615032 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -208,11 +208,22 @@ describe('ToolRuntime durable boundary', () => { order.push('lease-enter'); const proof = await operation(); order.push('successor-bundle'); + assert.equal(proof.durableOutcome.id, `${operationId}_response`); + assert.deepEqual(proof.durableOutcome.content, { + kind: 'function_response', + id: 'provider-call-1', + name: 'Write', + result: proof.content, + }); + assert.deepEqual(proof.durableOutcome.refs, { + operationId, + toolCallId: 'provider-call-1', + }); + assert.equal(Object.isFrozen(proof.durableOutcome), true); + assert.equal(Object.isFrozen(proof.durableOutcome.refs), true); return { kind: 'workspace_successor_committed', - durableOutcome: managedOutcomeEvent(operationId, proof.content, false, { - durationMs: proof.durationMs, - }), + durableOutcome: proof.durableOutcome, }; }, order); }, @@ -515,22 +526,16 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); - const result = { error: 'candidate was safely discarded' }; + const proof = await operation(); return { kind: 'safely_discarded', - providerResult: result, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: result }, - true, - ), + durableOutcome: proof.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => ({ error: 'candidate was safely discarded' })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -565,21 +570,16 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); + const proof = await operation(); return { kind: 'safely_discarded', - providerResult: ownerResult, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: { error: 'discarded-A' } }, - true, - ), + durableOutcome: proof.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => ownerResult); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -614,15 +614,10 @@ describe('ToolRuntime durable boundary', () => { operationId = input.operationId; return managedAdmission(async (operation) => { retainedOperation = operation; - const result = { error: 'candidate was safely discarded' }; + const proof = await operation(); return { kind: 'safely_discarded', - providerResult: result, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: result }, - true, - ), + durableOutcome: proof.durableOutcome, }; }); }, @@ -630,7 +625,7 @@ describe('ToolRuntime durable boundary', () => { ); const managedTool = tool(() => { implementationCalls += 1; - return { ok: true }; + return { error: 'candidate was safely discarded' }; }); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; @@ -641,7 +636,7 @@ describe('ToolRuntime durable boundary', () => { }); assert.ok(retainedOperation); await assert.rejects(retainedOperation(), /operation capability is closed/i); - assert.equal(implementationCalls, 0); + assert.equal(implementationCalls, 1); }); it('does not accept terminal settlement while a detached operation is running', async () => { @@ -667,7 +662,6 @@ describe('ToolRuntime durable boundary', () => { const result = { error: 'candidate was safely discarded' }; return { kind: 'safely_discarded', - providerResult: result, durableOutcome: managedOutcomeEvent( operationId, { kind: 'json', value: result }, @@ -722,7 +716,6 @@ describe('ToolRuntime durable boundary', () => { await operation(); return { kind: 'safely_discarded', - providerResult: { error: 'live provider error A' }, durableOutcome: managedOutcomeEvent( operationId, { kind: 'json', value: { error: 'durable replay error B' } }, @@ -733,7 +726,7 @@ describe('ToolRuntime durable boundary', () => { }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => ({ error: 'live provider error A' })); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -768,21 +761,16 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); + const proof = await operation(); return { kind: 'safely_discarded', - providerResult, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: { error: 'discarded' } }, - true, - ), + durableOutcome: proof.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => providerResult); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; @@ -816,28 +804,16 @@ describe('ToolRuntime durable boundary', () => { admitManagedMutation: async (input) => { operationId = input.operationId; return managedAdmission(async (operation) => { - await operation(); + const proof = await operation(); return { kind: 'safely_discarded', - providerResult: oversized, - durableOutcome: managedOutcomeEvent( - operationId, - { kind: 'json', value: oversized }, - true, - { - origin: 'code_mode', - modelVisibility: 'hidden', - toolCallId: 'nested-call-1', - parentToolCallId: 'exec-1', - parentOperationId: 'exec-op-1', - }, - ), + durableOutcome: proof.durableOutcome, }; }); }, }, ); - const managedTool = tool(() => ({ ok: true })); + const managedTool = tool(() => oversized); managedTool.name = 'Write'; managedTool.recoveryMode = 'reconcile'; managedTool.durableExecutionProfile = 'managed_mutation_v1'; diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index be4b45b9f3..a05422081f 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -1021,8 +1021,10 @@ function rewriteRuntimeEventActions( ): RuntimeEvent['actions'] { const dispatch = actions?.toolDispatch; const recovery = actions?.toolRecovery; - if (!dispatch && !recovery) return actions; - const operationId = dispatch?.operationId ?? recovery?.payload.operationId; + const managedTerminal = actions?.managedMutationTerminal; + if (!dispatch && !recovery && !managedTerminal) return actions; + const operationId = + dispatch?.operationId ?? recovery?.payload.operationId ?? managedTerminal?.operationId; const targetOperationId = operationId ? rewriteOwnedId(operationId, references.operationIds, 'tool operation') : undefined; @@ -1036,6 +1038,24 @@ function rewriteRuntimeEventActions( toolRecovery: rewriteToolRecoveryFact(recovery, targetOperationId, references), } : {}), + ...(managedTerminal && targetOperationId + ? { + managedMutationTerminal: { + ...managedTerminal, + operationId: targetOperationId, + dispatchEventId: requiredMappedId( + references.runtimeEventIds, + managedTerminal.dispatchEventId, + 'RuntimeEvent', + ), + outcomeEventId: requiredMappedId( + references.runtimeEventIds, + managedTerminal.outcomeEventId, + 'RuntimeEvent', + ), + }, + } + : {}), }; } diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index b679e7e895..96f5d6a7f0 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -303,6 +303,13 @@ export function projectRuntimeEventsToStoredMessages( projected = true; } + if (event.actions?.managedMutationTerminal) { + // The exact function_response owns the provider-visible result. This + // storage-owned fact only proves that the managed reservation ended + // without advancing the canonical workspace head. + projected = true; + } + if (event.actions?.artifactDelta) { // Artifact counters are storage bookkeeping. The tool result that owns the // artifact owns its row; this delta has none of its own. diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 65a4ca7e86..100d1ec805 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -469,6 +469,8 @@ export interface RuntimeManagedMutationOperationProof { readonly content: ToolResultContent; readonly isError: boolean; readonly durationMs: number; + /** Runtime-owned exact response envelope for the workspace successor transaction. */ + readonly durableOutcome: RuntimeEvent; } export type RuntimeManagedMutationSettlement = @@ -478,8 +480,6 @@ export type RuntimeManagedMutationSettlement = } | { readonly kind: 'safely_discarded'; - /** Exact value returned to the provider and canonicalized for durable replay. */ - readonly providerResult: unknown; readonly durableOutcome: RuntimeEvent; } | { readonly kind: 'unsettled'; readonly error: unknown }; @@ -496,6 +496,7 @@ export interface RuntimeManagedMutationAdmission { interface DurableToolAttempt { operationId: string; responseEventId: string; + buildOutcome(result: ToolResultContent, isError: boolean, durationMs: number): RuntimeEvent; commitOutcome( result: unknown, isError: boolean, @@ -1531,6 +1532,11 @@ export class ToolRuntime { content: value.outcome.content, isError: value.outcome.isError, durationMs: value.outcome.durationMs, + durableOutcome: durableAttempt!.buildOutcome( + value.outcome.content, + value.outcome.isError, + value.outcome.durationMs, + ), }; } finally { if (operationLifecycle.state === 'running') { @@ -1576,7 +1582,7 @@ export class ToolRuntime { // discarded; every other failure remains unsettled for recovery. throw new RuntimeManagedMutationUnsettledError(ownerError); } - const normalized = normalizeManagedMutationSettlement(settlement, ctx.maxResultBytes); + const normalized = normalizeManagedMutationSettlement(settlement); if (normalized.kind === 'workspace_successor_committed') { if (!runtimeOwnedValue) { throw new RuntimeManagedMutationUnsettledError( @@ -1591,9 +1597,14 @@ export class ToolRuntime { durableOutcome: normalized.durableOutcome, }; } else { + if (!runtimeOwnedValue) { + throw new RuntimeManagedMutationUnsettledError( + new Error('Managed mutation owner discarded the operation without executing it'), + ); + } settledExecution = { kind: 'managed', - value: normalized.value, + value: runtimeOwnedValue, durableOutcome: normalized.durableOutcome, }; } @@ -2047,6 +2058,10 @@ export class ToolRuntime { return { operationId, responseEventId: `${operationId}_response`, + buildOutcome: (result, isError, durationMs) => + deepFreezeRuntimeOwnedValue( + buildResponseEvent(result, isError, durationMs, this.input.now()), + ), commitOutcome: async (result, isError, durationMs) => { if (committedOutcome) return committedOutcome; const responseEvent = buildResponseEvent(result, isError, durationMs, this.input.now()); @@ -3088,17 +3103,13 @@ function uncertainOutcomeSignalFromError(error: unknown): ToolUncertainOutcomeSi }; } -function normalizeManagedMutationSettlement( - settlement: unknown, - maxResultBytes: number | undefined, -): +function normalizeManagedMutationSettlement(settlement: unknown): | { kind: 'workspace_successor_committed'; durableOutcome: RuntimeEvent; } | { kind: 'safely_discarded'; - value: RuntimeManagedMutationOperationValue; durableOutcome: RuntimeEvent; } { if (!settlement || typeof settlement !== 'object' || Array.isArray(settlement)) { @@ -3131,29 +3142,12 @@ function normalizeManagedMutationSettlement( }; } - if (!Object.hasOwn(record, 'providerResult')) { - throw new Error('Managed safely-discarded settlement has no provider result'); - } - const providerResult = snapshotManagedToolResult(record.providerResult, maxResultBytes); const response = durableOutcome.content; if (response?.kind !== 'function_response' || response.isError !== true) { throw new Error('Managed safely-discarded settlement has no durable error outcome'); } - const content = Object.freeze(coerceResultContent(providerResult)); - const outcome = Object.freeze({ - content, - isError: true, - durationMs: - typeof durableOutcome.actions?.stateDelta?.durationMs === 'number' - ? durableOutcome.actions.stateDelta.durationMs - : 0, - }); return { kind, - value: Object.freeze({ - result: providerResult, - outcome, - }), durableOutcome, }; } @@ -3337,6 +3331,13 @@ function consumeManagedResultBytes(budget: ManagedResultSnapshotBudget, bytes: n budget.bytes += bytes; } +function deepFreezeRuntimeOwnedValue(value: T, seen = new WeakSet()): T { + if (!value || typeof value !== 'object' || seen.has(value)) return value; + seen.add(value); + for (const nested of Object.values(value)) deepFreezeRuntimeOwnedValue(nested, seen); + return Object.freeze(value); +} + function coerceTerminalFailure( tool: MakaTool, cwd: string, diff --git a/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts b/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts index 0e4de3ad0f..f1eddd6204 100644 --- a/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts +++ b/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts @@ -6,6 +6,8 @@ import { requireManagedMutationCandidateAuthorityInternal } from '../../managed- import { openManagedWorkspaceOwner } from '../../managed-workspace-owner.js'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../../root-authority.js'; import { createSqliteRuntimeStore } from '../../sqlite-runtime-store.js'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; +import type { RuntimeEventManagedWorkspaceMutationV1 } from '@maka/core/runtime-event'; const storageRoot = requiredEnv('MAKA_GIT_WORKSPACE_STORAGE'); const gitRuntime = { @@ -47,6 +49,87 @@ if (process.env.MAKA_GIT_WORKSPACE_ACTION === 'execution-admission') { throw new Error('Managed execution admission crash child missed its failpoint'); } +if (process.env.MAKA_GIT_WORKSPACE_ACTION === 'managed-mutation-owner') { + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + if (!rootOwner) throw new Error('Unable to acquire crash-child root owner'); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime, + failpoint, + filesystemWorker: { + mutationExecutionProfileDigest: `sha256:${'a'.repeat(64)}`, + async execute(input) { + if (input.operation.kind !== 'write') throw new Error('Expected managed Write'); + const path = join(input.cwd, input.operation.path); + writeFileSync(path, input.operation.content, 'utf8'); + return { + kind: 'write' as const, + ok: true as const, + path, + bytes: Buffer.byteLength(input.operation.content, 'utf8'), + }; + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline(runtimeStore, request); + const operationId = 'operation-real-process-managed-write'; + const toolCallId = 'call-real-process-managed-write'; + const args = { path: 'tracked.txt', content: 'accepted before process crash\n' }; + const admission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { + operationId, + toolName: 'Write', + persistedArgs: args, + abortSignal: new AbortController().signal, + }); + await commitManagedMutationT1(runtimeStore, admission.durableDispatch, { + operationId, + toolCallId, + args, + }); + await admission.execute(async () => { + const result = await owner.executeManagedMutationFilesystemOperation({ + kind: 'write', + path: args.path, + content: args.content, + }); + if (result.kind !== 'write') throw new Error('Expected managed Write result'); + const content = { + kind: 'file_write' as const, + path: result.path, + bytes: result.bytes, + }; + return { + content, + isError: false, + durationMs: 1, + durableOutcome: { + id: `${operationId}_response`, + sessionId: 'session-real-process-managed-write', + invocationId: 'invocation-real-process-managed-write', + runId: 'run-real-process-managed-write', + turnId: 'turn-real-process-managed-write', + ts: 20, + partial: false, + role: 'tool' as const, + author: 'tool' as const, + origin: 'provider' as const, + modelVisibility: 'visible' as const, + content: { + kind: 'function_response' as const, + id: toolCallId, + name: 'Write', + result: content, + }, + refs: { operationId, toolCallId }, + actions: { stateDelta: { durationMs: 1 } }, + }, + }; + }); + throw new Error('Managed mutation owner crash child missed its failpoint'); +} + const service = createGitWorkspaceService({ storageRoot, gitRuntime, @@ -106,3 +189,65 @@ function requiredEnv(name: string): string { if (!value) throw new Error(`Missing ${name}`); return value; } + +async function commitManagedMutationT1( + store: ReturnType, + managedMutation: Readonly, + input: { + operationId: string; + toolCallId: string; + args: { path: string; content: string }; + }, +): Promise { + const identity = { + sessionId: 'session-real-process-managed-write', + invocationId: 'invocation-real-process-managed-write', + runId: 'run-real-process-managed-write', + turnId: 'turn-real-process-managed-write', + }; + const canonicalArgsHash = canonicalToolArgsHash('Write', input.args); + await store.commitToolPrepared({ + operationId: input.operationId, + journalEventId: `${input.operationId}_prepared`, + runtimeEvent: { + id: `${input.operationId}_call`, + ...identity, + ts: 10, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: input.toolCallId, + name: 'Write', + args: input.args, + }, + refs: { operationId: input.operationId, toolCallId: input.toolCallId }, + }, + dispatchRuntimeEvent: { + id: `${input.operationId}_dispatch`, + ...identity, + ts: 10, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: input.operationId, + providerToolCallId: input.toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile', + managedMutation, + }, + }, + refs: { operationId: input.operationId, toolCallId: input.toolCallId }, + }, + providerToolCallId: input.toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile', + committedAt: 10, + }); +} diff --git a/packages/storage/src/__tests__/managed-workspace-owner.test.ts b/packages/storage/src/__tests__/managed-workspace-owner.test.ts index c53075142c..4ad54b22be 100644 --- a/packages/storage/src/__tests__/managed-workspace-owner.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-owner.test.ts @@ -1,13 +1,19 @@ import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream, existsSync } from 'node:fs'; -import { mkdir, mkdtemp, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { promisify } from 'node:util'; import { afterEach, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { + RuntimeEvent, + RuntimeEventManagedWorkspaceMutationV1, +} from '@maka/core/runtime-event'; +import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { ManagedWorkspaceOwnerError, openManagedWorkspaceOwner, @@ -20,6 +26,7 @@ import { } from '../managed-workspace-execution-authority-internal.js'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js'; import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; +import { readActiveManagedMutationInternal } from '../workspace-version-authority-internal.js'; const execFileAsync = promisify(execFile); const cleanup: string[] = []; @@ -29,6 +36,8 @@ const { } = managedWorkspaceExecutionAuthorityTestSupport; let gitExecutablePath: string; let gitExecutableSha256: `sha256:${string}`; +const TEST_MUTATION_PROFILE = `sha256:${'a'.repeat(64)}` as const; +const RUN_REAL_PROCESS_CRASH_TESTS = process.env.MAKA_STORAGE_STRESS === '1'; before(async () => { gitExecutablePath = await findGitExecutable(); @@ -208,6 +217,453 @@ test('publishes only a revocable execution scope through its accepted handle', a } }); +test('freezes a managed Write admission from the owner-bound head and worker profile', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, + async execute() { + throw new Error('worker must not execute during admission'); + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + + const admission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { + operationId: 'operation-managed-write-1', + toolName: 'Write', + persistedArgs: { path: 'tracked.txt', content: 'updated\n' }, + abortSignal: new AbortController().signal, + }); + + assert.deepEqual(admission.durableDispatch, { + protocol: 'managed_mutation_v1', + repositoryId: accepted.head.repositoryId, + workspaceId: accepted.head.workspaceId, + workspaceEpochId: accepted.head.workspaceEpochId, + workspaceInstanceId: openRequest(sourceRoot).workspaceInstanceId, + objectFormat: 'sha1', + baseWorkspaceVersionId: accepted.head.workspaceVersionId, + baseAcceptedEventId: accepted.head.acceptedEventId, + baseHeadRevision: accepted.head.revision, + baseCommitOid: accepted.head.commitOid, + baseTreeOid: accepted.head.treeOid, + expectedPaths: ['tracked.txt'], + executionProfileDigest: admission.durableDispatch.executionProfileDigest, + }); + assert.match(admission.durableDispatch.executionProfileDigest, /^sha256:[a-f0-9]{64}$/u); + assert.equal('executionProfileDigest' in admission, false); + + await admission.dispose(); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('accepts a worker-owned Write only after capturing its Git candidate', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + const operationId = 'operation-managed-write-success'; + const toolCallId = 'call-managed-write-success'; + const args = { path: 'tracked.txt', content: 'updated by managed worker\n' }; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, + async execute(input) { + assert.equal(input.operation.kind, 'write'); + if (input.operation.kind !== 'write') throw new Error('expected Write'); + const target = join(input.cwd, input.operation.path); + await writeFile(target, input.operation.content, 'utf8'); + return { + kind: 'write' as const, + ok: true as const, + path: target, + bytes: Buffer.byteLength(input.operation.content, 'utf8'), + }; + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + const admission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { + operationId, + toolName: 'Write', + persistedArgs: args, + abortSignal: new AbortController().signal, + }); + await commitManagedMutationT1(runtimeStore, admission.durableDispatch, { + operationId, + toolCallId, + args, + }); + + const settlement = await admission.execute(async () => { + const result = await owner.executeManagedMutationFilesystemOperation( + { kind: 'write', path: args.path, content: args.content }, + new AbortController().signal, + ); + assert.equal(result.kind, 'write'); + const content = { + kind: 'file_write' as const, + path: result.kind === 'write' ? result.path : args.path, + bytes: result.kind === 'write' ? result.bytes : 0, + }; + return { + content, + isError: false, + durationMs: 1, + durableOutcome: managedMutationOutcome(operationId, toolCallId, content), + }; + }); + + assert.equal(settlement.kind, 'workspace_successor_committed'); + const head = await runtimeStore.readWorkspaceHead( + accepted.head.workspaceId, + accepted.head.workspaceEpochId, + ); + assert.equal(head?.revision, 2); + assert.notEqual(head?.commitOid, accepted.head.commitOid); + const { binding } = inspectManagedWorkspaceExecutionHandleInternal(accepted.executionHandle); + assert.equal(await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), args.content); + const events = await runtimeStore.readSessionRuntimeEvents('session-managed-mutation'); + assert.equal( + events.some((event) => event.id === `${operationId}_response`), + true, + ); + + const secondOperationId = 'operation-managed-write-success-2'; + const secondToolCallId = 'call-managed-write-success-2'; + const secondArgs = { path: 'tracked.txt', content: 'second managed version\n' }; + const secondAdmission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { + operationId: secondOperationId, + toolName: 'Write', + persistedArgs: secondArgs, + abortSignal: new AbortController().signal, + }); + assert.equal(secondAdmission.durableDispatch.baseHeadRevision, 2); + await commitManagedMutationT1(runtimeStore, secondAdmission.durableDispatch, { + operationId: secondOperationId, + toolCallId: secondToolCallId, + args: secondArgs, + }); + await secondAdmission.execute(async () => { + const result = await owner.executeManagedMutationFilesystemOperation( + { kind: 'write', path: secondArgs.path, content: secondArgs.content }, + new AbortController().signal, + ); + assert.equal(result.kind, 'write'); + const content = { + kind: 'file_write' as const, + path: result.kind === 'write' ? result.path : secondArgs.path, + bytes: result.kind === 'write' ? result.bytes : 0, + }; + return { + content, + isError: false, + durationMs: 1, + durableOutcome: managedMutationOutcome(secondOperationId, secondToolCallId, content), + }; + }); + const secondHead = await runtimeStore.readWorkspaceHead( + accepted.head.workspaceId, + accepted.head.workspaceEpochId, + ); + assert.equal(secondHead?.revision, 3); + assert.equal( + await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), + secondArgs.content, + ); + + await owner.close(); + await rootOwner.close(); + const reopenedRootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(reopenedRootOwner); + const reopenedOwner = await openManagedWorkspaceOwner({ + rootOwner: reopenedRootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, + async execute() { + throw new Error('worker must not execute while reopening an accepted successor'); + }, + }, + }); + const reopened = await reopenedOwner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + assert.equal(reopened.head.revision, 3); + assert.equal(reopened.head.commitOid, secondHead?.commitOid); + assert.equal( + inspectManagedWorkspaceExecutionHandleInternal(reopened.executionHandle).candidateReceipt + ?.candidateCommitOid, + secondHead?.commitOid, + ); + await reopenedOwner.close(); + await reopenedRootOwner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('atomically settles failed and successful no-effect Writes without advancing the head', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + const operationId = 'operation-managed-write-no-effect-error'; + const toolCallId = 'call-managed-write-no-effect-error'; + const args = { path: 'tracked.txt', content: 'unused\n' }; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, + async execute(input) { + assert.equal(input.operation.kind, 'write'); + if (input.operation.kind !== 'write') throw new Error('expected Write'); + const target = join(input.cwd, input.operation.path); + await writeFile(target, input.operation.content, 'utf8'); + return { + kind: 'write' as const, + ok: true as const, + path: target, + bytes: Buffer.byteLength(input.operation.content, 'utf8'), + }; + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + const admission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { + operationId, + toolName: 'Write', + persistedArgs: args, + abortSignal: new AbortController().signal, + }); + await commitManagedMutationT1(runtimeStore, admission.durableDispatch, { + operationId, + toolCallId, + args, + }); + const result = { kind: 'text' as const, text: 'Write failed before changing the workspace' }; + + const settlement = await admission.execute(async () => ({ + content: result, + isError: true, + durationMs: 1, + durableOutcome: managedMutationOutcome(operationId, toolCallId, result, true), + })); + + assert.equal(settlement.kind, 'safely_discarded'); + assert.equal( + ( + await runtimeStore.readWorkspaceHead( + accepted.head.workspaceId, + accepted.head.workspaceEpochId, + ) + )?.revision, + 1, + ); + assert.equal( + (await runtimeStore.readToolOperation(operationId))?.currentState, + 'outcome_committed', + ); + assert.equal( + await readActiveManagedMutationInternal( + runtimeStore, + openRequest(sourceRoot).workspaceInstanceId, + ), + undefined, + ); + + await runtimeStore.rebuildWorkspaceVersionProjections(); + assert.equal( + await readActiveManagedMutationInternal( + runtimeStore, + openRequest(sourceRoot).workspaceInstanceId, + ), + undefined, + ); + + const noChangeOperationId = 'operation-managed-write-no-change'; + const noChangeToolCallId = 'call-managed-write-no-change'; + const noChangeArgs = { path: 'tracked.txt', content: 'tracked\n' }; + const noChangeAdmission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { + operationId: noChangeOperationId, + toolName: 'Write', + persistedArgs: noChangeArgs, + abortSignal: new AbortController().signal, + }); + await commitManagedMutationT1(runtimeStore, noChangeAdmission.durableDispatch, { + operationId: noChangeOperationId, + toolCallId: noChangeToolCallId, + args: noChangeArgs, + }); + const noChangeContent = { + kind: 'file_write' as const, + path: 'tracked.txt', + bytes: Buffer.byteLength(noChangeArgs.content, 'utf8'), + }; + const noChangeSettlement = await noChangeAdmission.execute(async () => { + await owner.executeManagedMutationFilesystemOperation( + { kind: 'write', path: noChangeArgs.path, content: noChangeArgs.content }, + new AbortController().signal, + ); + return { + content: noChangeContent, + isError: false, + durationMs: 1, + durableOutcome: managedMutationOutcome( + noChangeOperationId, + noChangeToolCallId, + noChangeContent, + ), + }; + }); + assert.equal(noChangeSettlement.kind, 'safely_discarded'); + assert.equal( + ( + await runtimeStore.readWorkspaceHead( + accepted.head.workspaceId, + accepted.head.workspaceEpochId, + ) + )?.revision, + 1, + ); + await runtimeStore.rebuildWorkspaceVersionProjections(); + assert.equal( + await readActiveManagedMutationInternal( + runtimeStore, + openRequest(sourceRoot).workspaceInstanceId, + ), + undefined, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + +test('converges a managed Write after a real process crash between SQLite acceptance and Git projection', { + skip: !RUN_REAL_PROCESS_CRASH_TESTS, + timeout: 60_000, +}, async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const storageRoot = join(root, 'storage'); + const child = spawn( + process.execPath, + [fileURLToPath(new URL('./fixtures/git-workspace-service-crash-child.js', import.meta.url))], + { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + MAKA_GIT_WORKSPACE_ACTION: 'managed-mutation-owner', + MAKA_GIT_WORKSPACE_STORAGE: storageRoot, + MAKA_GIT_WORKSPACE_SOURCE: sourceRoot, + MAKA_GIT_WORKSPACE_EXECUTABLE: gitExecutablePath, + MAKA_GIT_WORKSPACE_SHA256: gitExecutableSha256, + MAKA_GIT_WORKSPACE_FAILPOINT: 'after_managed_successor_commit', + }, + }, + ); + try { + await waitForReady(child, 30_000); + child.kill('SIGKILL'); + await waitForExit(child); + + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, + async execute() { + throw new Error('reopen must project the accepted candidate without rerunning Write'); + }, + }, + }); + const reopened = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + assert.equal(reopened.head.revision, 2); + const { binding } = inspectManagedWorkspaceExecutionHandleInternal(reopened.executionHandle); + assert.equal( + await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), + 'accepted before process crash\n', + ); + assert.equal( + (await runtimeStore.readToolOperation('operation-real-process-managed-write')) + ?.currentState, + 'outcome_committed', + ); + assert.equal( + await readActiveManagedMutationInternal(runtimeStore, binding.workspaceInstanceId), + undefined, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +}); + test('does not let caller-mutated head state or a shadowed public reader forge execution authority', async () => { const root = await temporaryRoot(); const storageRoot = join(root, 'storage'); @@ -528,6 +984,7 @@ test('routes read-only execution through the owner-bound worker bridge', async ( expectedSha256: gitExecutableSha256, }, filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, async execute(input) { workerCalls.push(input); return { kind: 'read', content: 'ok' }; @@ -576,6 +1033,7 @@ test('revokes the scope and releases owner residency when the filesystem worker expectedSha256: gitExecutableSha256, }, filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, async execute() { throw new Error('simulated filesystem worker crash'); }, @@ -871,13 +1329,11 @@ test('rejects external drift instead of reopening a non-ready workspace', async await assert.rejects( owner.openManagedWorkspaceBaseline(runtimeStore, openRequest(sourceRoot)), (error: unknown) => - error instanceof Error && - error.name === 'GitWorkspaceServiceError' && - 'code' in error && - error.code === 'managed_workspace_drifted', + error instanceof ManagedWorkspaceOwnerError && + error.code === 'managed_workspace_quarantined', ); - assert.equal(existsSync(binding.worktreePath), true); + assert.equal(existsSync(binding.worktreePath), false); await owner.close(); } finally { runtimeStore.close(); @@ -943,6 +1399,98 @@ function openRequest(sourceRoot: string) { } as const; } +async function commitManagedMutationT1( + store: ReturnType, + managedMutation: Readonly, + input: { + operationId: string; + toolCallId: string; + args: { path: string; content: string }; + }, +): Promise { + const identity = { + sessionId: 'session-managed-mutation', + invocationId: 'invocation-managed-mutation', + runId: 'run-managed-mutation', + turnId: 'turn-managed-mutation', + }; + const canonicalArgsHash = canonicalToolArgsHash('Write', input.args); + await store.commitToolPrepared({ + operationId: input.operationId, + journalEventId: `${input.operationId}_prepared`, + runtimeEvent: { + id: `${input.operationId}_call`, + ...identity, + ts: 10, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: input.toolCallId, + name: 'Write', + args: input.args, + }, + refs: { operationId: input.operationId, toolCallId: input.toolCallId }, + }, + dispatchRuntimeEvent: { + id: `${input.operationId}_dispatch`, + ...identity, + ts: 10, + partial: false, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: input.operationId, + providerToolCallId: input.toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile', + managedMutation, + }, + }, + refs: { operationId: input.operationId, toolCallId: input.toolCallId }, + }, + providerToolCallId: input.toolCallId, + toolName: 'Write', + canonicalArgsHash, + recoveryMode: 'reconcile', + committedAt: 10, + }); +} + +function managedMutationOutcome( + operationId: string, + toolCallId: string, + result: unknown, + isError = false, +): RuntimeEvent { + return { + id: `${operationId}_response`, + sessionId: 'session-managed-mutation', + invocationId: 'invocation-managed-mutation', + runId: 'run-managed-mutation', + turnId: 'turn-managed-mutation', + ts: 20, + partial: false, + role: 'tool', + author: 'tool', + origin: 'provider', + modelVisibility: 'visible', + content: { + kind: 'function_response', + id: toolCallId, + name: 'Write', + result, + ...(isError ? { isError: true } : {}), + }, + refs: { operationId, toolCallId }, + actions: { stateDelta: { durationMs: 1 } }, + }; +} + async function git(cwd: string, ...args: string[]): Promise { const { stdout } = await execFileAsync('git', args, { cwd, @@ -951,3 +1499,45 @@ async function git(cwd: string, ...args: string[]): Promise { }); return stdout.trim(); } + +function waitForReady(child: ReturnType, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanupListeners(); + reject(new Error('Timed out waiting for managed mutation crash child')); + }, timeoutMs); + let stdout = ''; + let stderr = ''; + const onStdout = (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + if (!stdout.includes('READY\n')) return; + cleanupListeners(); + resolve(); + }; + const onStderr = (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanupListeners(); + reject( + new Error( + `Managed mutation crash child exited before READY (${code ?? signal ?? 'unknown'}): ${stderr}`, + ), + ); + }; + const cleanupListeners = () => { + clearTimeout(timeout); + child.stdout?.off('data', onStdout); + child.stderr?.off('data', onStderr); + child.off('exit', onExit); + }; + child.stdout?.on('data', onStdout); + child.stderr?.on('data', onStderr); + child.once('exit', onExit); + }); +} + +function waitForExit(child: ReturnType): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => child.once('exit', () => resolve())); +} diff --git a/packages/storage/src/__tests__/managed-workspace-worker-bridge-internal.test.ts b/packages/storage/src/__tests__/managed-workspace-worker-bridge-internal.test.ts index e169c5b061..b1fd092eae 100644 --- a/packages/storage/src/__tests__/managed-workspace-worker-bridge-internal.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-worker-bridge-internal.test.ts @@ -24,6 +24,7 @@ test('injects the owner-bound cwd and read-only boundary for allowed operations' const ownerToken = {}; const calls: unknown[] = []; const bridge = createManagedWorkspaceWorkerBridgeInternal(ownerToken, { + mutationExecutionProfileDigest: `sha256:${'a'.repeat(64)}`, async execute(input) { calls.push(input); switch (input.operation.kind) { @@ -33,6 +34,8 @@ test('injects the owner-bound cwd and read-only boundary for allowed operations' return { kind: 'glob', files: [] }; case 'grep': return { kind: 'grep', matches: [] }; + default: + throw new Error('mutating worker operation was not expected'); } }, }); @@ -75,6 +78,7 @@ test('rejects foreign, expired, mutating, and unknown operations before worker d const ownerToken = {}; let dispatches = 0; const bridge = createManagedWorkspaceWorkerBridgeInternal(ownerToken, { + mutationExecutionProfileDigest: `sha256:${'a'.repeat(64)}`, async execute() { dispatches += 1; return { kind: 'read', content: 'unexpected' }; diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index 7607ff7009..0ea319c7e5 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -195,7 +195,7 @@ describe('workspace version persistence authority', () => { }, }, }), - /managed mutation outcome requires the workspace successor writer/i, + /managed mutation outcome requires a workspace settlement writer/i, ); assert.equal((await store.readToolOperation(prepared.operationId))?.currentState, 'prepared'); }); @@ -759,6 +759,41 @@ describe('workspace version persistence authority', () => { }), /workspace version authority writer/i, ); + + const baseline = baselineInput(); + const opened = await commitWorkspaceBaselineInternal(store, baseline); + const prepared = managedPreparedCommit( + baseline, + opened.head, + 'workspace-terminal-bypass-operation', + ); + const mutation = prepared.dispatchRuntimeEvent.actions.toolDispatch.managedMutation; + await assert.rejects( + store.appendRuntimeEvent('session-1', 'run-1', { + id: 'workspace-terminal-bypass-event', + sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 2, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + actions: { + managedMutationTerminal: { + protocol: 'managed_mutation_terminal_v1', + disposition: 'safely_discarded', + reason: 'no_workspace_change', + operationId: prepared.operationId, + dispatchEventId: prepared.dispatchRuntimeEvent.id, + outcomeEventId: 'workspace-terminal-bypass-outcome', + mutation, + }, + }, + }), + /atomic workspace settlement writer/i, + ); }); }); diff --git a/packages/storage/src/execution-stores-workspace-authority-internal.ts b/packages/storage/src/execution-stores-workspace-authority-internal.ts new file mode 100644 index 0000000000..68182790d0 --- /dev/null +++ b/packages/storage/src/execution-stores-workspace-authority-internal.ts @@ -0,0 +1,21 @@ +import type { RuntimeWorkspaceVersionAuthorityStore } from '@maka/core/runtime-event-store'; + +const workspaceAuthorities = new WeakMap(); + +export function registerExecutionStoresWorkspaceAuthorityInternal( + stores: object, + authority: RuntimeWorkspaceVersionAuthorityStore, +): void { + if (workspaceAuthorities.has(stores)) { + throw new Error('Execution stores workspace authority is already registered'); + } + workspaceAuthorities.set(stores, authority); +} + +export function requireExecutionStoresWorkspaceAuthorityInternal( + stores: object, +): RuntimeWorkspaceVersionAuthorityStore { + const authority = workspaceAuthorities.get(stores); + if (!authority) throw new Error('Execution stores workspace authority is unavailable'); + return authority; +} diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index bc92edc549..b2ba4cfe35 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -7,6 +7,7 @@ import type { import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; import type { RuntimeContinuationAuthorityStore } from '@maka/core/runtime-event-store'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; +import { registerExecutionStoresWorkspaceAuthorityInternal } from './execution-stores-workspace-authority-internal.js'; import type { SessionListFilter } from '@maka/core/runtime-inputs'; import { createSqliteAgentRunStore, @@ -517,6 +518,7 @@ async function createExecutionStoresForWrite this.#createOrReuseManagedWorkspaceBaselineReceipt(binding), require: (request) => this.#requireManagedWorkspaceBaselineReceipt(request), + loadAcceptedContext: (request) => this.#loadManagedWorkspaceAcceptedContext(request), verify: (receipt) => this.#verifyManagedWorkspaceBaselineReceipt(receipt), }); registerManagedMutationCandidateAuthorityInternal(this, { capture: (request) => this.#captureManagedMutationCandidate(request), + require: (binding, operationId) => + this.#requireManagedMutationCandidate(binding, operationId), + accept: (binding, receipt) => this.#acceptManagedMutationCandidate(binding, receipt), discard: (receipt) => this.#discardManagedMutationCandidate(receipt), }); } @@ -652,6 +657,35 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { }); } + async #loadManagedWorkspaceAcceptedContext( + input: CreateManagedWorkspaceFromSourceInput, + ): Promise { + const runtime = await this.runtime.verify(); + assertOpenIdentity(input); + return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, input); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + const receipt = await readBaselineReceipt(layout.baselineReceiptPath); + if (!receipt) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Canonical workspace baseline receipt is unavailable', + ); + } + assertBindingMatches(receipt.binding, input, layout, runtime.digest); + const sourceRoot = await canonicalDirectory(input.sourceRoot, 'repository_ineligible'); + const source = await this.inspectSourceRepository(sourceRoot); + const repository = await this.requireRepository(receipt.binding, layout); + assertBindingRepository(receipt.binding, repository); + const epoch = await this.requireEpochArtifact(receipt.binding, repository, layout); + assertBindingEpoch(receipt.binding, epoch); + assertEpochArtifactMatches(epoch, input, source, repository); + const summary = await this.readBaselineTreeSummary(receipt.binding, layout); + assertBaselineReceiptMatches(receipt, receipt.binding, summary); + return receipt; + }); + } + async #createOrReuseManagedWorkspaceBaselineReceipt( binding: ManagedWorkspaceBinding, ): Promise { @@ -914,6 +948,182 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { }); } + async #acceptManagedMutationCandidate( + inputBinding: ManagedWorkspaceBinding, + inputReceipt: ManagedMutationCandidateReceiptV1, + ): Promise { + const binding = Object.freeze({ ...inputBinding }); + const receipt = snapshotMutationCandidateReceipt(inputReceipt); + await withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, binding); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + const baselineReceipt = await readBaselineReceipt(layout.baselineReceiptPath); + if (!baselineReceipt) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Canonical workspace baseline receipt is unavailable', + ); + } + const summary = await this.requireVerifiedMutationContext( + binding, + layout, + binding.gitRuntimeSha256, + ); + assertBaselineReceiptMatches(baselineReceipt, binding, summary); + const identity = mutationCandidateIdentity(receipt.operationId, receipt.workspaceEpochId); + const receiptPath = join(layout.mutationCandidateRoot, `${identity.digest}.json`); + const durable = await readMutationCandidateReceipt(receiptPath); + if (!durable || !isDeepStrictEqual(durable, receipt)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed mutation candidate receipt changed before acceptance', + ); + } + assertMutationCandidateReceiptMatches( + receipt, + { + binding, + operationId: receipt.operationId, + baseHead: receipt.baseHead, + expectedPaths: receipt.changedPaths, + executionProfileDigest: receipt.executionProfileDigest, + }, + identity.ref, + baselineReceipt.policyHash, + ); + await this.assertMutationCandidateArtifact(receipt, layout); + + const [head, tree, headRef, status] = await Promise.all([ + this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD'], layout.homePath), + this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD^{tree}'], layout.homePath), + this.runtime.run( + ['--git-dir', binding.repositoryPath, 'rev-parse', binding.headRef], + layout.homePath, + ), + this.runtime.runBuffer( + [ + '--literal-pathspecs', + '-C', + binding.worktreePath, + 'status', + '--porcelain=v1', + '-z', + '--untracked-files=all', + '--ignored=matching', + ], + layout.homePath, + ), + ]); + if ( + head.trim() === receipt.candidateCommitOid && + tree.trim() === receipt.candidateTreeOid && + headRef.trim() === receipt.candidateCommitOid && + status.length === 0 + ) { + return; + } + if ( + head.trim() !== receipt.baseHead.commitOid || + tree.trim() !== receipt.baseHead.treeOid || + headRef.trim() !== receipt.baseHead.commitOid + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + 'Managed worktree no longer matches the candidate acceptance boundary', + ); + } + const entries = parsePorcelainStatus(status); + if (!sameStringSet(entries.map((entry) => entry.path).sort(), receipt.changedPaths)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + 'Managed worktree changed after candidate capture', + ); + } + await this.updateExistingRefCas( + binding.repositoryPath, + binding.headRef, + receipt.candidateCommitOid, + receipt.baseHead.commitOid, + layout.homePath, + ); + await this.runtime.run( + ['-C', binding.worktreePath, 'reset', '--hard', receipt.candidateCommitOid], + layout.homePath, + ); + const [acceptedHead, acceptedTree, acceptedStatus] = await Promise.all([ + this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD'], layout.homePath), + this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD^{tree}'], layout.homePath), + this.runtime.run( + [ + '--literal-pathspecs', + '-C', + binding.worktreePath, + 'status', + '--porcelain=v1', + '--untracked-files=all', + '--ignored=matching', + ], + layout.homePath, + ), + ]); + if ( + acceptedHead.trim() !== receipt.candidateCommitOid || + acceptedTree.trim() !== receipt.candidateTreeOid || + acceptedStatus.trim() !== '' + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + 'Managed mutation candidate did not become the clean worktree head', + ); + } + }); + } + + async #requireManagedMutationCandidate( + inputBinding: ManagedWorkspaceBinding, + operationId: string, + ): Promise { + const binding = Object.freeze({ ...inputBinding }); + const runtime = await this.runtime.verify(); + return withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, binding); + await assertOwnedManagedWorkspaceLayout(canonicalStorageRoot, layout); + const baselineReceipt = await readBaselineReceipt(layout.baselineReceiptPath); + if (!baselineReceipt) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Canonical workspace baseline receipt is unavailable', + ); + } + const summary = await this.requireVerifiedMutationContext(binding, layout, runtime.digest); + assertBaselineReceiptMatches(baselineReceipt, binding, summary); + const identity = mutationCandidateIdentity(operationId, binding.workspaceEpochId); + const receipt = await readMutationCandidateReceipt( + join(layout.mutationCandidateRoot, `${identity.digest}.json`), + ); + if (!receipt) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + `Managed mutation candidate receipt is unavailable: ${operationId}`, + ); + } + assertMutationCandidateReceiptMatches( + receipt, + { + binding, + operationId, + baseHead: receipt.baseHead, + expectedPaths: receipt.changedPaths, + executionProfileDigest: receipt.executionProfileDigest, + }, + identity.ref, + baselineReceipt.policyHash, + ); + await this.assertMutationCandidateArtifact(receipt, layout); + return receipt; + }); + } + private async inspectMutationCandidateInput( request: ManagedMutationCandidateRequest, layout: WorkspaceLayout, @@ -984,7 +1194,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { const entries = parsePorcelainStatus(status); if (entries.length === 0) { throw new GitWorkspaceServiceError( - 'managed_mutation_candidate_rejected', + 'managed_mutation_no_change', 'Managed mutation did not change the workspace', ); } @@ -1771,6 +1981,40 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { } } + private async updateExistingRefCas( + repositoryPath: string, + ref: string, + desiredOid: string, + expectedOid: string, + homePath: string, + ): Promise { + const current = ( + await this.runtime.runOptional( + ['--git-dir', repositoryPath, 'rev-parse', '--verify', '--quiet', ref], + 1, + ) + )?.trim(); + if (current === desiredOid) return; + if (current !== expectedOid) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + `Managed Git ref does not match its accepted predecessor: ${ref}`, + ); + } + try { + await this.runtime.run( + ['--git-dir', repositoryPath, 'update-ref', ref, desiredOid, expectedOid], + homePath, + ); + } catch (error) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + `Managed Git ref changed during successor acceptance: ${ref}`, + { cause: error }, + ); + } + } + private async deleteRefCas( repositoryPath: string, ref: string, diff --git a/packages/storage/src/managed-baseline-receipt-authority-internal.ts b/packages/storage/src/managed-baseline-receipt-authority-internal.ts index fb2f37e5f8..df87757e8c 100644 --- a/packages/storage/src/managed-baseline-receipt-authority-internal.ts +++ b/packages/storage/src/managed-baseline-receipt-authority-internal.ts @@ -8,6 +8,10 @@ import type { export interface ManagedBaselineReceiptAuthorityInternal { issue(binding: ManagedWorkspaceBinding): Promise; require(input: CreateManagedWorkspaceFromSourceInput): Promise; + /** Loads immutable baseline evidence without requiring the worktree to remain at baseline HEAD. */ + loadAcceptedContext( + input: CreateManagedWorkspaceFromSourceInput, + ): Promise; verify(receipt: ManagedWorkspaceBaselineReceiptV1): Promise; } diff --git a/packages/storage/src/managed-mutation-candidate-authority-internal.ts b/packages/storage/src/managed-mutation-candidate-authority-internal.ts index 09b1caa679..f69f5088ea 100644 --- a/packages/storage/src/managed-mutation-candidate-authority-internal.ts +++ b/packages/storage/src/managed-mutation-candidate-authority-internal.ts @@ -34,6 +34,14 @@ export interface ManagedMutationCandidateReceiptV1 { export interface ManagedMutationCandidateAuthorityInternal { capture(request: ManagedMutationCandidateRequest): Promise; + require( + binding: ManagedWorkspaceBinding, + operationId: string, + ): Promise; + accept( + binding: ManagedWorkspaceBinding, + receipt: ManagedMutationCandidateReceiptV1, + ): Promise; discard(receipt: ManagedMutationCandidateReceiptV1): Promise; } diff --git a/packages/storage/src/managed-workspace-execution-authority-internal.ts b/packages/storage/src/managed-workspace-execution-authority-internal.ts index 9e3c798744..08946236df 100644 --- a/packages/storage/src/managed-workspace-execution-authority-internal.ts +++ b/packages/storage/src/managed-workspace-execution-authority-internal.ts @@ -4,6 +4,7 @@ import type { ManagedWorkspaceBaselineReceiptV1, ManagedWorkspaceBinding, } from './git-workspace-service.js'; +import type { ManagedMutationCandidateReceiptV1 } from './managed-mutation-candidate-authority-internal.js'; export interface ManagedWorkspaceExecutionHandle { readonly kind: 'managed_workspace_execution_handle_v1'; @@ -32,6 +33,20 @@ export interface ManagedWorkspaceExecutionAuthorityStateInternal { readonly binding: Readonly; readonly receipt: Readonly; readonly head: Readonly; + readonly candidateReceipt?: Readonly; +} + +export function advanceManagedWorkspaceExecutionHandleInternal( + ownerToken: object, + handle: ManagedWorkspaceExecutionHandle, + head: Readonly, + candidateReceipt: Readonly, +): void { + const state = states.get(handle); + if (!state || state.ownerToken !== ownerToken) { + throw new Error('Managed workspace execution handle is invalid for this owner'); + } + states.set(handle, { ...state, head, candidateReceipt, ownerToken }); } const states = new WeakMap< @@ -41,7 +56,7 @@ const states = new WeakMap< } >(); -export interface ManagedWorkspaceExecutionScopeStateInternal { +interface ManagedWorkspaceReadOnlyScopeStateInternal { readonly provisioning: 'canonical_tree_only_v1'; readonly workspaceEffect: 'none'; readonly cwd: string; @@ -49,6 +64,20 @@ export interface ManagedWorkspaceExecutionScopeStateInternal { readonly head: Readonly; } +interface ManagedWorkspaceMutationScopeStateInternal { + readonly provisioning: 'canonical_tree_only_v1'; + readonly workspaceEffect: 'mutation'; + readonly cwd: string; + readonly binding: Readonly; + readonly head: Readonly; + readonly operationId: string; + readonly expectedPaths: readonly string[]; +} + +export type ManagedWorkspaceExecutionScopeStateInternal = + | ManagedWorkspaceReadOnlyScopeStateInternal + | ManagedWorkspaceMutationScopeStateInternal; + const scopes = new WeakMap< object, ManagedWorkspaceExecutionScopeStateInternal & { diff --git a/packages/storage/src/managed-workspace-owner.ts b/packages/storage/src/managed-workspace-owner.ts index 99765efcf3..f67f8168cb 100644 --- a/packages/storage/src/managed-workspace-owner.ts +++ b/packages/storage/src/managed-workspace-owner.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { createHash } from 'node:crypto'; import { createGitWorkspaceService, type CreateManagedWorkspaceFromSourceInput, @@ -14,6 +15,7 @@ import { type ManagedBaselineReceiptAuthorityInternal, } from './managed-baseline-receipt-authority-internal.js'; import { + advanceManagedWorkspaceExecutionHandleInternal, issueManagedWorkspaceExecutionHandleInternal, issueManagedWorkspaceExecutionScopeInternal, requireManagedWorkspaceExecutionHandleInternal, @@ -21,15 +23,29 @@ import { type ManagedWorkspaceExecutionHandle, type ManagedWorkspaceExecutionScope, } from './managed-workspace-execution-authority-internal.js'; +import { + requireManagedMutationCandidateAuthorityInternal, + type ManagedMutationCandidateAuthorityInternal, + type ManagedMutationCandidateReceiptV1, +} from './managed-mutation-candidate-authority-internal.js'; import { createManagedWorkspaceWorkerBridgeInternal, type ManagedWorkspaceFilesystemWorker, + type ManagedWorkspaceMutationOperation, + type ManagedWorkspaceMutationResult, type ManagedWorkspaceReadOnlyOperation, type ManagedWorkspaceReadOnlyResult, type ManagedWorkspaceWorkerBridgeInternal, } from './managed-workspace-worker-bridge-internal.js'; import type { RuntimeWorkspaceVersionAuthorityStore } from '@maka/core/runtime-event-store'; -import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; +import { + isCanonicalManagedMutationPathV1, + type RuntimeEventManagedWorkspaceMutationV1, +} from '@maka/core/runtime-event'; +import type { + WorkspaceHeadRecordV1, + WorkspaceVersionRecordV1, +} from '@maka/core/workspace-version-authority'; import { assertInteractiveRootOwner, authenticateInteractiveRootOwner, @@ -41,8 +57,13 @@ import { assertWorkspaceBaselineAuthorityStoreRootInternal, bindWorkspaceBaselineAuthorityStoreRootInternal, commitWorkspaceBaselineInternal, + commitManagedMutationTerminalInternal, + commitWorkspaceSuccessorInternal, + readActiveManagedMutationInternal, readWorkspaceHeadInternal, } from './workspace-version-authority-internal.js'; +import type { InteractiveExecutionStoresWriter } from './execution-stores.js'; +import { requireExecutionStoresWorkspaceAuthorityInternal } from './execution-stores-workspace-authority-internal.js'; // RuntimeEvent order is assigned by the SQLite authority spine. M0 therefore // uses a protocol-fixed logical timestamp and keeps unauthenticated wall-clock @@ -81,7 +102,9 @@ export type ManagedWorkspaceOwnerFailpoint = | 'after_initial_store_root_validation' | 'after_baseline_authority_commit' | 'after_post_commit_artifact_verification' - | 'after_execution_artifact_verification'; + | 'after_execution_artifact_verification' + | 'after_managed_successor_commit' + | 'after_managed_candidate_accept'; export type OpenManagedWorkspaceBaselineInput = CreateManagedWorkspaceFromSourceInput; @@ -91,21 +114,66 @@ export interface OpenManagedWorkspaceBaselineResult { readonly executionHandle: ManagedWorkspaceExecutionHandle; } +export interface AdmitManagedWorkspaceMutationInput { + readonly operationId: string; + readonly toolName: 'Write' | 'Edit'; + readonly persistedArgs: unknown; + readonly abortSignal: AbortSignal; +} + +export interface ManagedWorkspaceMutationAdmission { + readonly durableDispatch: Readonly; + execute( + operation: () => Promise, + ): Promise; + /** Idempotent before T1 or after an admission is otherwise abandoned. */ + dispose(): Promise; +} + +export interface ManagedWorkspaceMutationOperationProof { + readonly content: unknown; + readonly isError: boolean; + readonly durationMs: number; + readonly durableOutcome: import('@maka/core/runtime-event').RuntimeEvent; +} + +export type ManagedWorkspaceMutationSettlement = + | { + readonly kind: 'workspace_successor_committed'; + readonly durableOutcome: import('@maka/core/runtime-event').RuntimeEvent; + } + | { + readonly kind: 'safely_discarded'; + readonly durableOutcome: import('@maka/core/runtime-event').RuntimeEvent; + }; + export interface ManagedWorkspaceOwner { readonly state: 'ready' | 'closing' | 'closed'; openManagedWorkspaceBaseline( store: RuntimeWorkspaceVersionAuthorityStore, input: OpenManagedWorkspaceBaselineInput, ): Promise; + openManagedWorkspaceBaselineFromExecutionStores( + stores: InteractiveExecutionStoresWriter, + input: OpenManagedWorkspaceBaselineInput, + ): Promise; withManagedWorkspaceExecution( handle: ManagedWorkspaceExecutionHandle, operation: (scope: ManagedWorkspaceExecutionScope) => Promise, ): Promise; + admitManagedWorkspaceMutation( + handle: ManagedWorkspaceExecutionHandle, + input: AdmitManagedWorkspaceMutationInput, + ): Promise; executeReadOnlyFilesystemOperation( scope: ManagedWorkspaceExecutionScope, operation: ManagedWorkspaceReadOnlyOperation, abortSignal?: AbortSignal, ): Promise; + executeManagedMutationFilesystemOperation( + operation: ManagedWorkspaceMutationOperation, + abortSignal?: AbortSignal, + ): Promise; close(): Promise; } @@ -151,6 +219,7 @@ export async function openManagedWorkspaceOwner( rootOwner, service, requireManagedBaselineReceiptAuthorityInternal(service), + requireManagedMutationCandidateAuthorityInternal(service), input.failpoint, input.filesystemWorker, ); @@ -173,6 +242,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { readonly #drainWaiters = new Set<() => void>(); readonly #assertCurrentRootIdentity: () => Promise; readonly #executionContext = new AsyncLocalStorage(); + readonly #mutationExecutionContext = new AsyncLocalStorage(); #closeTask: Promise | undefined; readonly #executionOwnerToken = {}; readonly #workerBridge: ManagedWorkspaceWorkerBridgeInternal | undefined; @@ -181,6 +251,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { private readonly rootOwner: InteractiveRootOwner, private readonly service: GitWorkspaceService, private readonly receiptAuthority: ManagedBaselineReceiptAuthorityInternal, + private readonly mutationCandidateAuthority: ManagedMutationCandidateAuthorityInternal, private readonly failpoint?: (point: ManagedWorkspaceOwnerFailpoint) => void | Promise, filesystemWorker?: ManagedWorkspaceFilesystemWorker, ) { @@ -217,23 +288,34 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { input.workspaceId, input.workspaceEpochId, ); - const receipt = existingHead ? await this.receiptAuthority.require(input) : undefined; - const binding = receipt - ? await this.#openReadyBinding(receipt.binding) - : await this.#requireReady(await this.service.createManagedWorkspaceFromSource(input)); - const durableReceipt = receipt ?? (await this.receiptAuthority.issue(binding)); - if ( - existingHead && - (existingHead.workspaceVersionId !== durableReceipt.workspaceVersionId || - existingHead.acceptedEventId !== durableReceipt.baselineAcceptedEventId || - existingHead.commitOid !== binding.baselineCommitOid || - existingHead.treeOid !== binding.baselineTreeOid) - ) { - throw new ManagedWorkspaceOwnerError( - 'managed_workspace_owner_unavailable', - 'Canonical workspace head does not match its durable Git baseline receipt', + const receipt = existingHead + ? await this.receiptAuthority.loadAcceptedContext(input) + : undefined; + let acceptedCandidate: ManagedMutationCandidateReceiptV1 | undefined; + let binding: ManagedWorkspaceBinding; + if (!receipt) { + binding = await this.#requireReady( + await this.service.createManagedWorkspaceFromSource(input), + ); + } else if (isBaselineWorkspaceHead(existingHead!, receipt, receipt.binding)) { + binding = await this.#openReadyBinding(receipt.binding); + } else { + binding = receipt.binding; + const version = await store.readWorkspaceVersion(existingHead!.workspaceVersionId); + if (!version || version.origin.kind !== 'tool_mutation') { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Canonical managed workspace successor evidence is unavailable', + ); + } + acceptedCandidate = await this.mutationCandidateAuthority.require( + binding, + version.origin.operationId, ); + assertAcceptedSuccessorCandidate(binding, existingHead!, version, acceptedCandidate); + await this.mutationCandidateAuthority.accept(binding, acceptedCandidate); } + const durableReceipt = receipt ?? (await this.receiptAuthority.issue(binding)); const committed = await commitWorkspaceBaselineInternal(store, { epochOpenedEventId: durableReceipt.epochOpenedEventId, baselineAcceptedEventId: durableReceipt.baselineAcceptedEventId, @@ -272,7 +354,11 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { // Canonical acceptance never makes a missing Git artifact acceptable. // Reverify after the SQLite transaction so post-accept artifact loss is // reported fail-closed instead of returning a usable workspace head. - await this.receiptAuthority.verify(durableReceipt); + if (acceptedCandidate) { + await this.mutationCandidateAuthority.accept(binding, acceptedCandidate); + } else { + await this.receiptAuthority.verify(durableReceipt); + } await this.failpoint?.('after_post_commit_artifact_verification'); // The root marker is mutable host state and is not covered by receipt // verification. Revalidate its identity at the final return gate without @@ -289,11 +375,24 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { binding: internalBinding, receipt: internalReceipt, head: internalHead, + ...(acceptedCandidate + ? { candidateReceipt: Object.freeze(structuredClone(acceptedCandidate)) } + : {}), }), }; }); } + openManagedWorkspaceBaselineFromExecutionStores( + stores: InteractiveExecutionStoresWriter, + input: OpenManagedWorkspaceBaselineInput, + ): Promise { + return this.openManagedWorkspaceBaseline( + requireExecutionStoresWorkspaceAuthorityInternal(stores), + input, + ); + } + async withManagedWorkspaceExecution( handle: ManagedWorkspaceExecutionHandle, operation: (scope: ManagedWorkspaceExecutionScope) => Promise, @@ -312,12 +411,17 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { { cause: error }, ); } - assertExecutionCrossPlaneIdentity(accepted.binding, accepted.receipt, accepted.head); + assertExecutionCrossPlaneIdentity( + accepted.binding, + accepted.receipt, + accepted.head, + accepted.candidateReceipt, + ); // Test builds with a failpoint retain a preliminary proof so crash tests // can stop after a real, completed artifact verification. The ordinary // production path performs only the final proof below. if (this.failpoint) { - await this.#verifyExecutionArtifactOrQuarantine(accepted.binding, accepted.receipt); + await this.#verifyExecutionStateOrQuarantine(accepted); await this.failpoint('after_execution_artifact_verification'); } @@ -325,7 +429,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { // proof bundle. Verify the durable Git artifact first, then make the // immutable workspace head the final durable reread before scope issue. await this.#assertCurrentRootIdentity(); - await this.#verifyExecutionArtifactOrQuarantine(accepted.binding, accepted.receipt); + await this.#verifyExecutionStateOrQuarantine(accepted); const currentHead = await readWorkspaceHeadInternal( accepted.store, accepted.binding.workspaceId, @@ -348,7 +452,12 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { 'Managed workspace execution handle no longer matches the canonical workspace head', ); } - assertExecutionCrossPlaneIdentity(accepted.binding, accepted.receipt, currentHead); + assertExecutionCrossPlaneIdentity( + accepted.binding, + accepted.receipt, + currentHead, + accepted.candidateReceipt, + ); const binding = accepted.binding; const scope = issueManagedWorkspaceExecutionScopeInternal(this.#executionOwnerToken, { provisioning: 'canonical_tree_only_v1', @@ -365,6 +474,243 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { }); } + async admitManagedWorkspaceMutation( + handle: ManagedWorkspaceExecutionHandle, + input: AdmitManagedWorkspaceMutationInput, + ): Promise { + return this.#run(async () => { + input.abortSignal.throwIfAborted(); + if (!this.#workerBridge) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_worker_unavailable', + 'Managed workspace mutation requires the sandboxed filesystem worker', + ); + } + if (!/^[A-Za-z0-9_-]{1,128}$/u.test(input.operationId)) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_execution_handle_invalid', + 'Managed workspace mutation operation identity is invalid', + ); + } + let accepted; + try { + accepted = requireManagedWorkspaceExecutionHandleInternal( + this.#executionOwnerToken, + handle, + ); + } catch (error) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_execution_handle_invalid', + 'Managed workspace execution handle is invalid for this owner', + { cause: error }, + ); + } + assertExecutionCrossPlaneIdentity( + accepted.binding, + accepted.receipt, + accepted.head, + accepted.candidateReceipt, + ); + await this.#assertCurrentRootIdentity(); + await this.#verifyExecutionStateOrQuarantine(accepted); + const currentHead = await readWorkspaceHeadInternal( + accepted.store, + accepted.binding.workspaceId, + accepted.binding.workspaceEpochId, + ); + await assertWorkspaceBaselineAuthorityStoreRootInternal( + accepted.store, + this.rootOwner.capability.canonicalPath, + ); + bindWorkspaceBaselineAuthorityStoreRootInternal( + accepted.store, + this.rootOwner.capability.rootId, + ); + if (!currentHead || !sameWorkspaceHead(currentHead, accepted.head)) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Managed mutation base no longer matches the canonical workspace head', + ); + } + assertExecutionCrossPlaneIdentity( + accepted.binding, + accepted.receipt, + currentHead, + accepted.candidateReceipt, + ); + const active = await readActiveManagedMutationInternal( + accepted.store, + accepted.binding.workspaceInstanceId, + ); + if (active) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + `Managed workspace already has active mutation ${active.operationId}`, + ); + } + const expectedPath = canonicalManagedMutationPath(input.toolName, input.persistedArgs); + input.abortSignal.throwIfAborted(); + const durableDispatch = Object.freeze({ + protocol: 'managed_mutation_v1' as const, + repositoryId: accepted.binding.repositoryId, + workspaceId: accepted.binding.workspaceId, + workspaceEpochId: accepted.binding.workspaceEpochId, + workspaceInstanceId: accepted.binding.workspaceInstanceId, + objectFormat: accepted.binding.objectFormat, + baseWorkspaceVersionId: currentHead.workspaceVersionId, + baseAcceptedEventId: currentHead.acceptedEventId, + baseHeadRevision: currentHead.revision, + baseCommitOid: currentHead.commitOid, + baseTreeOid: currentHead.treeOid, + expectedPaths: Object.freeze([expectedPath]), + executionProfileDigest: this.#workerBridge.mutationExecutionProfileDigest, + }); + let state: 'open' | 'executing' | 'closed' = 'open'; + const execute = async ( + operation: () => Promise, + ): Promise => { + if (state !== 'open') { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Managed workspace mutation admission is no longer active', + ); + } + state = 'executing'; + try { + return await this.#run(async () => { + input.abortSignal.throwIfAborted(); + const reservation = await readActiveManagedMutationInternal( + accepted.store, + accepted.binding.workspaceInstanceId, + ); + assertManagedMutationReservation(reservation, input.operationId, durableDispatch); + const scope = issueManagedWorkspaceExecutionScopeInternal(this.#executionOwnerToken, { + provisioning: 'canonical_tree_only_v1', + workspaceEffect: 'mutation', + cwd: accepted.binding.worktreePath, + binding: accepted.binding, + head: freezeWorkspaceHead(currentHead), + operationId: input.operationId, + expectedPaths: durableDispatch.expectedPaths, + }); + try { + const proof = await this.#executionContext.run(this.#executionOwnerToken, () => + this.#mutationExecutionContext.run(scope, operation), + ); + if (proof.isError) { + await this.#requireReady(accepted.binding); + await commitManagedMutationTerminalInternal(accepted.store, { + reason: 'operation_failed_no_effect', + toolOutcome: { + operationId: input.operationId, + journalEventId: `${input.operationId}_outcome`, + runtimeEvent: proof.durableOutcome, + committedAt: proof.durableOutcome.ts, + }, + }); + return Object.freeze({ + kind: 'safely_discarded' as const, + durableOutcome: proof.durableOutcome, + }); + } + let candidate: ManagedMutationCandidateReceiptV1; + try { + candidate = await this.mutationCandidateAuthority.capture({ + binding: accepted.binding, + operationId: input.operationId, + baseHead: currentHead, + expectedPaths: durableDispatch.expectedPaths, + executionProfileDigest: durableDispatch.executionProfileDigest, + }); + } catch (error) { + if ( + !(error instanceof GitWorkspaceServiceError) || + error.code !== 'managed_mutation_no_change' + ) { + throw error; + } + await this.#requireReady(accepted.binding); + await commitManagedMutationTerminalInternal(accepted.store, { + reason: 'no_workspace_change', + toolOutcome: { + operationId: input.operationId, + journalEventId: `${input.operationId}_outcome`, + runtimeEvent: proof.durableOutcome, + committedAt: proof.durableOutcome.ts, + }, + }); + return Object.freeze({ + kind: 'safely_discarded' as const, + durableOutcome: proof.durableOutcome, + }); + } + const identity = successorIdentity(candidate); + const committed = await commitWorkspaceSuccessorInternal(accepted.store, { + successor: { + acceptedEventId: identity.acceptedEventId, + committedAt: proof.durableOutcome.ts, + successor: { + repositoryId: candidate.repositoryId, + workspaceId: candidate.workspaceId, + workspaceEpochId: candidate.workspaceEpochId, + workspaceVersionId: identity.workspaceVersionId, + objectFormat: candidate.objectFormat, + parentWorkspaceVersionId: currentHead.workspaceVersionId, + baseAcceptedEventId: currentHead.acceptedEventId, + baseHeadRevision: currentHead.revision, + commitOid: candidate.candidateCommitOid, + treeOid: candidate.candidateTreeOid, + policyHash: candidate.workspacePolicyHash, + treeDeltaDigest: candidate.treeDeltaDigest, + changedPaths: candidate.changedPaths, + changedFileCount: candidate.changedPaths.length, + deletedFileCount: candidate.deletedPaths.length, + executionProfileDigest: candidate.executionProfileDigest, + }, + origin: { + operationId: input.operationId, + dispatchEventId: reservation!.dispatchEventId, + outcomeEventId: proof.durableOutcome.id, + }, + }, + toolOutcome: { + operationId: input.operationId, + journalEventId: `${input.operationId}_outcome`, + runtimeEvent: proof.durableOutcome, + committedAt: proof.durableOutcome.ts, + }, + }); + await this.failpoint?.('after_managed_successor_commit'); + await this.mutationCandidateAuthority.accept(accepted.binding, candidate); + await this.failpoint?.('after_managed_candidate_accept'); + advanceManagedWorkspaceExecutionHandleInternal( + this.#executionOwnerToken, + handle, + freezeWorkspaceHead(committed.head), + Object.freeze(structuredClone(candidate)), + ); + return Object.freeze({ + kind: 'workspace_successor_committed' as const, + durableOutcome: proof.durableOutcome, + }); + } finally { + revokeManagedWorkspaceExecutionScopeInternal(this.#executionOwnerToken, scope); + } + }); + } finally { + state = 'closed'; + } + }; + return Object.freeze({ + durableDispatch, + execute, + async dispose() { + if (state === 'open') state = 'closed'; + }, + }); + }); + } + async executeReadOnlyFilesystemOperation( scope: ManagedWorkspaceExecutionScope, operation: ManagedWorkspaceReadOnlyOperation, @@ -379,6 +725,26 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { return await this.#workerBridge.execute(scope, operation, abortSignal); } + async executeManagedMutationFilesystemOperation( + operation: ManagedWorkspaceMutationOperation, + abortSignal?: AbortSignal, + ): Promise { + if (!this.#workerBridge) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_worker_unavailable', + 'Managed workspace filesystem worker is unavailable', + ); + } + const scope = this.#mutationExecutionContext.getStore(); + if (!scope) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_execution_handle_invalid', + 'Managed mutation worker is available only inside its active admission', + ); + } + return await this.#workerBridge.executeMutation(scope, operation, abortSignal); + } + close(): Promise { if (this.#executionContext.getStore() === this.#executionOwnerToken) { return Promise.reject( @@ -483,6 +849,118 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { ); } } + + async #verifyExecutionStateOrQuarantine( + accepted: ReturnType, + ): Promise { + if (!accepted.candidateReceipt) { + await this.#verifyExecutionArtifactOrQuarantine(accepted.binding, accepted.receipt); + return; + } + try { + await this.mutationCandidateAuthority.accept(accepted.binding, accepted.candidateReceipt); + } catch (error) { + if ( + !(error instanceof GitWorkspaceServiceError) || + error.code !== 'managed_workspace_drifted' + ) { + throw error; + } + const quarantine = await this.service.quarantineManagedWorkspace( + accepted.binding, + 'external_workspace_drift', + ); + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_quarantined', + `Managed workspace drift was quarantined at ${quarantine.quarantinePath}`, + { cause: error }, + ); + } + } +} + +function canonicalManagedMutationPath(toolName: 'Write' | 'Edit', persistedArgs: unknown): string { + if (!persistedArgs || typeof persistedArgs !== 'object' || Array.isArray(persistedArgs)) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + `Managed ${toolName} arguments are invalid`, + ); + } + const record = persistedArgs as Record; + const expectedKeys = + toolName === 'Write' ? ['content', 'path'] : ['new_string', 'old_string', 'path']; + if ( + Object.keys(record).sort().join('\0') !== expectedKeys.join('\0') || + typeof record.path !== 'string' || + (toolName === 'Write' && typeof record.content !== 'string') || + (toolName === 'Edit' && + (typeof record.old_string !== 'string' || typeof record.new_string !== 'string')) + ) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + `Managed ${toolName} arguments are invalid`, + ); + } + const path = process.platform === 'win32' ? record.path.replaceAll('\\', '/') : record.path; + if (!isCanonicalManagedMutationPathV1(path)) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Managed workspace mutation path is not a canonical tracked file path', + ); + } + return path; +} + +function assertManagedMutationReservation( + reservation: Awaited>, + operationId: string, + dispatch: RuntimeEventManagedWorkspaceMutationV1, +): void { + if ( + !reservation || + reservation.operationId !== operationId || + reservation.workspaceInstanceId !== dispatch.workspaceInstanceId || + reservation.repositoryId !== dispatch.repositoryId || + reservation.workspaceId !== dispatch.workspaceId || + reservation.workspaceEpochId !== dispatch.workspaceEpochId || + reservation.baseWorkspaceVersionId !== dispatch.baseWorkspaceVersionId || + reservation.baseAcceptedEventId !== dispatch.baseAcceptedEventId || + reservation.baseHeadRevision !== dispatch.baseHeadRevision || + reservation.baseCommitOid !== dispatch.baseCommitOid || + reservation.baseTreeOid !== dispatch.baseTreeOid || + reservation.executionProfileDigest !== dispatch.executionProfileDigest || + reservation.expectedPaths.length !== dispatch.expectedPaths.length || + reservation.expectedPaths.some((path, index) => path !== dispatch.expectedPaths[index]) + ) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Managed mutation reservation does not match its owner-issued admission', + ); + } +} + +function successorIdentity(receipt: { + readonly operationId: string; + readonly workspaceEpochId: string; + readonly candidateCommitOid: string; + readonly candidateTreeOid: string; +}): { acceptedEventId: string; workspaceVersionId: string } { + const digest = createHash('sha256') + .update( + JSON.stringify({ + protocol: 'maka_managed_workspace_successor_identity_v1', + operationId: receipt.operationId, + workspaceEpochId: receipt.workspaceEpochId, + candidateCommitOid: receipt.candidateCommitOid, + candidateTreeOid: receipt.candidateTreeOid, + }), + 'utf8', + ) + .digest('hex'); + return { + acceptedEventId: `workspace-successor-${digest.slice(0, 32)}`, + workspaceVersionId: `version_${digest.slice(32)}`, + }; } function sameWorkspaceHead(left: WorkspaceHeadRecordV1, right: WorkspaceHeadRecordV1): boolean { @@ -514,21 +992,90 @@ function freezeManagedWorkspaceReceipt( return Object.freeze({ ...receipt, binding: freezeManagedWorkspaceBinding(receipt.binding) }); } +function isBaselineWorkspaceHead( + head: WorkspaceHeadRecordV1, + receipt: ManagedWorkspaceBaselineReceiptV1, + binding: ManagedWorkspaceBinding, +): boolean { + return ( + head.workspaceVersionId === receipt.workspaceVersionId && + head.acceptedEventId === receipt.baselineAcceptedEventId && + head.commitOid === binding.baselineCommitOid && + head.treeOid === binding.baselineTreeOid && + head.revision === 1 + ); +} + +function assertAcceptedSuccessorCandidate( + binding: ManagedWorkspaceBinding, + head: WorkspaceHeadRecordV1, + version: WorkspaceVersionRecordV1, + candidate: ManagedMutationCandidateReceiptV1, +): void { + if (version.protocol !== 'workspace_version_accepted_v1') { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Canonical successor does not match its owner-derived Git candidate', + ); + } + const changedPathsMatch = + version.changedPaths.length === candidate.changedPaths.length && + version.changedPaths.every((path, index) => path === candidate.changedPaths[index]); + if ( + version.repositoryId !== binding.repositoryId || + version.workspaceId !== binding.workspaceId || + version.workspaceEpochId !== binding.workspaceEpochId || + version.workspaceVersionId !== head.workspaceVersionId || + version.acceptedEventId !== head.acceptedEventId || + version.commitOid !== head.commitOid || + version.treeOid !== head.treeOid || + version.origin.operationId !== candidate.operationId || + version.parents[0] !== candidate.baseHead.workspaceVersionId || + version.baseAcceptedEventId !== candidate.baseHead.acceptedEventId || + version.baseHeadRevision !== candidate.baseHead.revision || + head.revision !== candidate.baseHead.revision + 1 || + candidate.candidateCommitOid !== head.commitOid || + candidate.candidateTreeOid !== head.treeOid || + version.policyHash !== candidate.workspacePolicyHash || + version.treeDeltaDigest !== candidate.treeDeltaDigest || + version.executionProfileDigest !== candidate.executionProfileDigest || + version.changedFileCount !== candidate.changedPaths.length || + version.deletedFileCount !== candidate.deletedPaths.length || + !changedPathsMatch + ) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Canonical successor does not match its owner-derived Git candidate', + ); + } +} + function assertExecutionCrossPlaneIdentity( binding: ManagedWorkspaceBinding, receipt: ManagedWorkspaceBaselineReceiptV1, head: Readonly, + candidateReceipt?: Readonly, ): void { - if ( + const commonMismatch = !sameManagedWorkspaceBinding(binding, receipt.binding) || head.repositoryId !== binding.repositoryId || head.workspaceId !== binding.workspaceId || - head.workspaceEpochId !== binding.workspaceEpochId || - head.workspaceVersionId !== receipt.workspaceVersionId || - head.acceptedEventId !== receipt.baselineAcceptedEventId || - head.commitOid !== binding.baselineCommitOid || - head.treeOid !== binding.baselineTreeOid - ) { + head.workspaceEpochId !== binding.workspaceEpochId; + const baselineMismatch = + !candidateReceipt && + (head.workspaceVersionId !== receipt.workspaceVersionId || + head.acceptedEventId !== receipt.baselineAcceptedEventId || + head.commitOid !== binding.baselineCommitOid || + head.treeOid !== binding.baselineTreeOid); + const candidateMismatch = + candidateReceipt && + (candidateReceipt.repositoryId !== binding.repositoryId || + candidateReceipt.workspaceId !== binding.workspaceId || + candidateReceipt.workspaceEpochId !== binding.workspaceEpochId || + candidateReceipt.workspaceInstanceId !== binding.workspaceInstanceId || + candidateReceipt.candidateCommitOid !== head.commitOid || + candidateReceipt.candidateTreeOid !== head.treeOid); + if (commonMismatch || baselineMismatch || candidateMismatch) { throw new ManagedWorkspaceOwnerError( 'managed_workspace_owner_unavailable', 'Managed workspace execution evidence does not identify one exact accepted boundary', diff --git a/packages/storage/src/managed-workspace-worker-bridge-internal.ts b/packages/storage/src/managed-workspace-worker-bridge-internal.ts index 18512833ad..33c333fb57 100644 --- a/packages/storage/src/managed-workspace-worker-bridge-internal.ts +++ b/packages/storage/src/managed-workspace-worker-bridge-internal.ts @@ -2,7 +2,10 @@ import { createManagedExecutionBoundary, type ExecutionBoundary, } from '@maka/core/sandbox-boundary'; -import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; +import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; import { requireManagedWorkspaceExecutionScopeInternal, type ManagedWorkspaceExecutionScope, @@ -31,8 +34,25 @@ export type ManagedWorkspaceReadOnlyOperation = readonly timeoutMs: number; }; +export type ManagedWorkspaceMutationOperation = + | { + readonly kind: 'write'; + readonly path: string; + readonly content: string; + } + | { + readonly kind: 'edit'; + readonly path: string; + readonly oldString: string; + readonly newString: string; + }; + +export type ManagedWorkspaceFilesystemOperation = + | ManagedWorkspaceReadOnlyOperation + | ManagedWorkspaceMutationOperation; + interface ManagedWorkspaceFilesystemWorkerInput { - readonly operation: ManagedWorkspaceReadOnlyOperation; + readonly operation: ManagedWorkspaceFilesystemOperation; readonly cwd: string; readonly executionBoundary: ExecutionBoundary; readonly abortSignal?: AbortSignal; @@ -48,7 +68,32 @@ export type ManagedWorkspaceReadOnlyResult = | { readonly kind: 'glob'; readonly files: readonly string[] } | { readonly kind: 'grep'; readonly matches: readonly string[] }; +export type ManagedWorkspaceMutationResult = + | { + readonly kind: 'write'; + readonly ok: true; + readonly path: string; + readonly bytes: number; + readonly diff?: string; + } + | { + readonly kind: 'edit'; + readonly ok: true; + readonly path: string; + readonly replacements: 1; + readonly matchedVia: 'exact' | 'line-trimmed' | 'whitespace' | 'escape'; + readonly startLine: number; + readonly endLine: number; + readonly diff?: string; + }; + +export type ManagedWorkspaceFilesystemResult = + | ManagedWorkspaceReadOnlyResult + | ManagedWorkspaceMutationResult; + export interface ManagedWorkspaceFilesystemWorker { + /** Host-issued digest of the exact worker protocol and mutation sandbox profile. */ + readonly mutationExecutionProfileDigest: `sha256:${string}`; /** * Resolves only after the one-shot filesystem operation and every process it * owns have reached a terminal lifecycle state. Implementations must not @@ -56,7 +101,7 @@ export interface ManagedWorkspaceFilesystemWorker { * satisfies this contract through FilesystemWorkerClient; M1.2 admits only * read-only operations, so a host crash cannot leave a workspace mutation. */ - execute(input: ManagedWorkspaceFilesystemWorkerInput): Promise; + execute(input: ManagedWorkspaceFilesystemWorkerInput): Promise; } export type ManagedWorkspaceWorkerBridgeErrorCode = 'managed_workspace_operation_denied'; @@ -72,11 +117,17 @@ export class ManagedWorkspaceWorkerBridgeError extends Error { } export interface ManagedWorkspaceWorkerBridgeInternal { + readonly mutationExecutionProfileDigest: `sha256:${string}`; execute( scope: ManagedWorkspaceExecutionScope, operation: ManagedWorkspaceReadOnlyOperation, abortSignal?: AbortSignal, ): Promise; + executeMutation( + scope: ManagedWorkspaceExecutionScope, + operation: ManagedWorkspaceMutationOperation, + abortSignal?: AbortSignal, + ): Promise; } /** @@ -88,7 +139,14 @@ export function createManagedWorkspaceWorkerBridgeInternal( ownerToken: object, worker: ManagedWorkspaceFilesystemWorker, ): ManagedWorkspaceWorkerBridgeInternal { + if (!/^sha256:[a-f0-9]{64}$/u.test(worker.mutationExecutionProfileDigest)) { + throw new ManagedWorkspaceWorkerBridgeError( + 'managed_workspace_operation_denied', + 'Managed workspace mutation worker profile identity is invalid', + ); + } const bridge: ManagedWorkspaceWorkerBridgeInternal = { + mutationExecutionProfileDigest: worker.mutationExecutionProfileDigest, async execute( scope: ManagedWorkspaceExecutionScope, operation: ManagedWorkspaceReadOnlyOperation, @@ -107,12 +165,55 @@ export function createManagedWorkspaceWorkerBridgeInternal( 'Managed workspace execution scope does not permit filesystem mutation', ); } - return await worker.execute({ + const result = await worker.execute({ operation, cwd: state.cwd, executionBoundary: createManagedExecutionBoundary(createReadOnlyPermissionProfile(), 0), ...(abortSignal ? { abortSignal } : {}), }); + if (!isReadOnlyResult(result)) { + throw new ManagedWorkspaceWorkerBridgeError( + 'managed_workspace_operation_denied', + 'Managed workspace read operation returned a mutating result', + ); + } + return result; + }, + async executeMutation(scope, operation, abortSignal) { + if (!isMutationOperation(operation)) { + throw new ManagedWorkspaceWorkerBridgeError( + 'managed_workspace_operation_denied', + 'Managed workspace mutation permits only Write and Edit operations', + ); + } + const state = requireManagedWorkspaceExecutionScopeInternal(ownerToken, scope); + if ( + state.workspaceEffect !== 'mutation' || + state.provisioning !== 'canonical_tree_only_v1' || + state.expectedPaths.length !== 1 || + operation.path !== state.expectedPaths[0] + ) { + throw new ManagedWorkspaceWorkerBridgeError( + 'managed_workspace_operation_denied', + 'Managed workspace mutation does not match its admitted path', + ); + } + const result = await worker.execute({ + operation, + cwd: state.cwd, + executionBoundary: createManagedExecutionBoundary( + createWorkspaceWritePermissionProfile(), + 0, + ), + ...(abortSignal ? { abortSignal } : {}), + }); + if (!isMutationResult(result) || result.kind !== operation.kind) { + throw new ManagedWorkspaceWorkerBridgeError( + 'managed_workspace_operation_denied', + 'Managed workspace mutation worker returned a mismatched result', + ); + } + return result; }, }; return Object.freeze(bridge); @@ -123,3 +224,26 @@ function isReadOnlyOperation(input: unknown): input is ManagedWorkspaceReadOnlyO const kind = (input as { kind?: unknown }).kind; return kind === 'read' || kind === 'glob' || kind === 'grep'; } + +function isMutationOperation(input: unknown): input is ManagedWorkspaceMutationOperation { + if (!input || typeof input !== 'object') return false; + const kind = (input as { kind?: unknown }).kind; + return kind === 'write' || kind === 'edit'; +} + +function isReadOnlyResult( + input: ManagedWorkspaceFilesystemResult, +): input is ManagedWorkspaceReadOnlyResult { + return ( + input.kind === 'read' || + input.kind === 'read_image' || + input.kind === 'glob' || + input.kind === 'grep' + ); +} + +function isMutationResult( + input: ManagedWorkspaceFilesystemResult, +): input is ManagedWorkspaceMutationResult { + return input.kind === 'write' || input.kind === 'edit'; +} diff --git a/packages/storage/src/runtime-event-authority.ts b/packages/storage/src/runtime-event-authority.ts index f6b3b031bf..2383a57ee9 100644 --- a/packages/storage/src/runtime-event-authority.ts +++ b/packages/storage/src/runtime-event-authority.ts @@ -14,4 +14,9 @@ export function assertNoReservedWorkspaceAuthorityAppend(event: RuntimeEvent): v if (event.sessionId === WORKSPACE_AUTHORITY_SESSION_ID) { throw new Error('RuntimeEvent targets the reserved workspace authority stream'); } + if (event.actions?.managedMutationTerminal !== undefined) { + throw new Error( + 'Managed mutation terminal facts require the atomic workspace settlement writer', + ); + } } diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 9d32bc63b6..b117c9dd33 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -23,11 +23,13 @@ import { } from '@maka/core/workspace-version-authority'; import { decodeRuntimeEvent, + isRuntimeManagedWorkspaceMutationTerminal, isPartialRuntimeEvent, isTerminalRuntimeEvent, TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent, type RuntimeEventManagedWorkspaceMutationV1, + type RuntimeEventManagedWorkspaceMutationTerminalV1, type ToolRecoveryMode, } from '@maka/core/runtime-event'; import { @@ -77,6 +79,8 @@ import { } from './sqlite-runtime-schema.js'; import { registerWorkspaceBaselineAuthorityWriterInternal, + type ManagedMutationTerminalCommitInput, + type ManagedMutationTerminalCommitResult, type WorkspaceSuccessorCommitInput, type WorkspaceSuccessorCommitResult, } from './workspace-version-authority-internal.js'; @@ -1444,6 +1448,139 @@ export class SqliteRuntimeStore }); } + async #commitManagedMutationTerminal( + input: ManagedMutationTerminalCommitInput, + rootId: string, + ): Promise { + const toolOutcome: CommitToolOutcomeInput = { + ...input.toolOutcome, + runtimeEvent: canonicalizeRuntimeEventForStorage(input.toolOutcome.runtimeEvent), + }; + assertNoReservedWorkspaceAuthorityAppend(toolOutcome.runtimeEvent); + assertOutcomeInput(toolOutcome); + + return this.transaction(() => { + this.#assertWorkspaceStorageRootBinding(rootId); + const authority = this.readCanonicalWorkspaceAuthoritySync(); + this.assertWorkspaceProjectionsMatchSync(authority); + const operation = this.readToolOperationSync(toolOutcome.operationId); + if ( + !operation || + operation.dispatchEventId === undefined || + operation.recoveryMode !== 'reconcile' || + (operation.toolName !== 'Write' && operation.toolName !== 'Edit') + ) { + throw new Error('Managed mutation terminal requires one prepared Write/Edit operation'); + } + const dispatchJson = this.readRuntimeEventJson(operation.dispatchEventId); + const dispatchEvent = dispatchJson + ? decodeRuntimeEvent(JSON.parse(dispatchJson) as unknown) + : undefined; + const mutation = dispatchEvent?.actions?.toolDispatch?.managedMutation; + if (!dispatchEvent || !mutation) { + throw new Error('Managed mutation terminal is missing its exact durable T1'); + } + const terminalEvent = buildManagedMutationTerminalEvent({ + dispatchEvent, + mutation, + outcomeEvent: toolOutcome.runtimeEvent, + reason: input.reason, + }); + const existingTerminalJson = this.readRuntimeEventJson(terminalEvent.id); + if (operation.resultEventId !== undefined || existingTerminalJson !== undefined) { + if (operation.resultEventId !== toolOutcome.runtimeEvent.id || !existingTerminalJson) { + throw new Error('Managed mutation terminal retry conflicts with its committed outcome'); + } + assertStoredRuntimeEventEquals( + toolOutcome.runtimeEvent, + this.readRuntimeEventJson(operation.resultEventId), + ); + assertStoredRuntimeEventEquals(terminalEvent, existingTerminalJson); + return { + created: false, + outcomeRuntimeEventSeq: this.runtimeEventSeq(operation.resultEventId), + }; + } + if (operation.currentState !== 'prepared') { + throw new Error('Managed mutation terminal requires one prepared operation'); + } + const currentHead = authority.heads.find( + (candidate) => + candidate.workspaceId === mutation.workspaceId && + candidate.workspaceEpochId === mutation.workspaceEpochId, + ); + const reservation = this.db + .prepare(` + SELECT + workspace_instance_id, operation_id, dispatch_event_id, + base_workspace_version_id, base_accepted_event_id, base_head_revision, + base_commit_oid, base_tree_oid, expected_paths_json, execution_profile_digest + FROM runtime_managed_mutation_reservations + WHERE operation_id = ? + `) + .get(operation.operationId) as + | Pick< + ManagedMutationReservationProjectionRow, + | 'workspace_instance_id' + | 'operation_id' + | 'dispatch_event_id' + | 'base_workspace_version_id' + | 'base_accepted_event_id' + | 'base_head_revision' + | 'base_commit_oid' + | 'base_tree_oid' + | 'expected_paths_json' + | 'execution_profile_digest' + > + | undefined; + if ( + !currentHead || + !reservation || + currentHead.workspaceVersionId !== mutation.baseWorkspaceVersionId || + currentHead.acceptedEventId !== mutation.baseAcceptedEventId || + currentHead.revision !== mutation.baseHeadRevision || + currentHead.commitOid !== mutation.baseCommitOid || + currentHead.treeOid !== mutation.baseTreeOid || + reservation.workspace_instance_id !== mutation.workspaceInstanceId || + reservation.operation_id !== operation.operationId || + reservation.dispatch_event_id !== operation.dispatchEventId || + reservation.base_workspace_version_id !== mutation.baseWorkspaceVersionId || + reservation.base_accepted_event_id !== mutation.baseAcceptedEventId || + reservation.base_head_revision !== mutation.baseHeadRevision || + reservation.base_commit_oid !== mutation.baseCommitOid || + reservation.base_tree_oid !== mutation.baseTreeOid || + reservation.execution_profile_digest !== mutation.executionProfileDigest || + !isDeepStrictEqual(JSON.parse(reservation.expected_paths_json), mutation.expectedPaths) + ) { + throw new Error('Managed mutation terminal requires its exact active reservation and head'); + } + const response = toolOutcome.runtimeEvent.content; + if ( + response?.kind !== 'function_response' || + (input.reason === 'operation_failed_no_effect') !== (response.isError === true) + ) { + throw new Error('Managed mutation terminal reason conflicts with its exact tool outcome'); + } + + const outcomeResult = this.commitToolOutcomeSync(toolOutcome, 'workspace_terminal'); + this.insertRuntimeEvent(terminalEvent, toolOutcome.committedAt, false); + const released = this.db + .prepare(` + DELETE FROM runtime_managed_mutation_reservations + WHERE workspace_instance_id = ? AND operation_id = ? AND dispatch_event_id = ? + `) + .run(mutation.workspaceInstanceId, operation.operationId, operation.dispatchEventId); + if (released.changes !== 1) { + throw new Error('Managed mutation terminal reservation release compare-and-set failed'); + } + this.assertWorkspaceProjectionsMatchSync(this.readCanonicalWorkspaceAuthoritySync()); + return { + created: true, + outcomeRuntimeEventSeq: outcomeResult.runtimeEventSeq, + }; + }); + } + private registerWorkspaceBaselineAuthorityWriter(databasePath: string): void { const readWorkspaceHead = this.readWorkspaceHead.bind(this); registerWorkspaceBaselineAuthorityWriterInternal( @@ -1451,6 +1588,7 @@ export class SqliteRuntimeStore databasePath, (input, rootId) => this.#commitWorkspaceBaseline(input, rootId), (input, rootId) => this.#commitWorkspaceSuccessor(input, rootId), + (input, rootId) => this.#commitManagedMutationTerminal(input, rootId), (rootId) => this.#bindWorkspaceStorageRoot(rootId), readWorkspaceHead, (workspaceInstanceId) => this.#readActiveManagedMutation(workspaceInstanceId), @@ -1708,9 +1846,11 @@ export class SqliteRuntimeStore ); } } + const terminalOperations = scanManagedMutationTerminalFacts(events, toolScan); const activeManagedMutations = this.scanCanonicalManagedMutationReservationsSync( toolScan, scan, + terminalOperations, ); this.options.failpoint?.('after_workspace_canonical_scan'); return { ...scan, activeManagedMutations }; @@ -1719,6 +1859,7 @@ export class SqliteRuntimeStore private scanCanonicalManagedMutationReservationsSync( toolScan: ReturnType, authority: ReturnType, + terminalOperations: ReadonlySet, ): ManagedMutationReservationProjectionRow[] { const acceptedOperations = new Set( authority.successors.map((candidate) => candidate.successor.origin.operationId), @@ -1741,7 +1882,12 @@ export class SqliteRuntimeStore `Corrupt managed mutation reservation: identity_conflict at ${dispatchEvent?.id ?? operation.operationId}`, ); } - if (acceptedOperations.has(operation.operationId)) continue; + if ( + acceptedOperations.has(operation.operationId) || + terminalOperations.has(operation.operationId) + ) { + continue; + } if (operation.responseEvent) { throw new Error( `Corrupt managed mutation reservation: generic_outcome at ${operation.responseEvent.id}`, @@ -2649,7 +2795,7 @@ export class SqliteRuntimeStore private commitToolOutcomeSync( input: CommitToolOutcomeInput, - settlementOwner: 'generic' | 'workspace_successor' = 'generic', + settlementOwner: 'generic' | 'workspace_successor' | 'workspace_terminal' = 'generic', ): ToolCommitResult { const operation = this.readToolOperationSync(input.operationId); if (!operation) throw new Error(`Unknown tool operation ${input.operationId}`); @@ -2682,8 +2828,8 @@ export class SqliteRuntimeStore if (!reservation) { throw new Error('Managed mutation T1 is missing its durable reservation'); } - if (settlementOwner !== 'workspace_successor') { - throw new Error('Managed mutation outcome requires the workspace successor writer'); + if (settlementOwner === 'generic') { + throw new Error('Managed mutation outcome requires a workspace settlement writer'); } } const runtimeEventSeq = this.insertRuntimeEvent(input.runtimeEvent, input.committedAt, false); @@ -4111,6 +4257,107 @@ function managedMutationMatchesAcceptedSuccessor( ); } +function buildManagedMutationTerminalEvent(input: { + readonly dispatchEvent: RuntimeEvent; + readonly mutation: RuntimeEventManagedWorkspaceMutationV1; + readonly outcomeEvent: RuntimeEvent; + readonly reason: RuntimeEventManagedWorkspaceMutationTerminalV1['reason']; +}): RuntimeEvent { + const dispatch = input.dispatchEvent.actions?.toolDispatch; + if (!dispatch) throw new Error('Managed mutation terminal requires a dispatch event'); + const terminal: RuntimeEventManagedWorkspaceMutationTerminalV1 = { + protocol: 'managed_mutation_terminal_v1', + disposition: 'safely_discarded', + reason: input.reason, + operationId: dispatch.operationId, + dispatchEventId: input.dispatchEvent.id, + outcomeEventId: input.outcomeEvent.id, + mutation: structuredClone(input.mutation), + }; + if (!isRuntimeManagedWorkspaceMutationTerminal(terminal)) { + throw new Error('Invalid managed mutation terminal fact'); + } + const digest = createHash('sha256') + .update(`${dispatch.operationId}\0${input.dispatchEvent.id}\0${input.outcomeEvent.id}`) + .digest('hex') + .slice(0, 32); + return { + id: `managed_terminal_${digest}`, + sessionId: input.dispatchEvent.sessionId, + invocationId: input.dispatchEvent.invocationId, + runId: input.dispatchEvent.runId, + turnId: input.dispatchEvent.turnId, + ts: input.outcomeEvent.ts, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + actions: { managedMutationTerminal: terminal }, + }; +} + +function scanManagedMutationTerminalFacts( + events: readonly RuntimeEvent[], + toolScan: ReturnType, +): ReadonlySet { + const terminalOperations = new Set(); + const eventOrder = new Map(events.map((event, index) => [event.id, index])); + for (const event of events) { + const terminal = event.actions?.managedMutationTerminal; + if (!terminal) continue; + const operation = toolScan.operations.find( + (candidate) => candidate.operationId === terminal.operationId, + ); + const dispatchEvent = operation?.dispatchEvent; + const dispatch = dispatchEvent?.actions?.toolDispatch; + const response = operation?.responseEvent; + const expectedTerminalEvent = + dispatchEvent && dispatch?.managedMutation && response + ? buildManagedMutationTerminalEvent({ + dispatchEvent, + mutation: dispatch.managedMutation, + outcomeEvent: response, + reason: terminal.reason, + }) + : undefined; + const actionKeys = event.actions ? Object.keys(event.actions) : []; + if ( + !isRuntimeManagedWorkspaceMutationTerminal(terminal) || + event.partial || + event.role !== 'system' || + event.author !== 'system' || + event.modelVisibility !== 'hidden' || + event.content !== undefined || + event.status !== undefined || + event.refs !== undefined || + actionKeys.length !== 1 || + actionKeys[0] !== 'managedMutationTerminal' || + !operation || + operation.issues.length > 0 || + !dispatchEvent || + !dispatch || + !response || + !expectedTerminalEvent || + !isDeepStrictEqual(event, expectedTerminalEvent) || + terminal.dispatchEventId !== dispatchEvent.id || + terminal.outcomeEventId !== response.id || + !isDeepStrictEqual(terminal.mutation, dispatch.managedMutation) || + event.sessionId !== dispatchEvent.sessionId || + event.invocationId !== dispatchEvent.invocationId || + event.runId !== dispatchEvent.runId || + event.turnId !== dispatchEvent.turnId || + (eventOrder.get(event.id) ?? -1) <= (eventOrder.get(response.id) ?? -1) || + (terminal.reason === 'operation_failed_no_effect') !== + (response.content?.kind === 'function_response' && response.content.isError === true) || + terminalOperations.has(terminal.operationId) + ) { + throw new Error(`Corrupt managed mutation terminal fact: identity_conflict at ${event.id}`); + } + terminalOperations.add(terminal.operationId); + } + return terminalOperations; +} + function workspaceEpochProjectionRow( authority: ScannedWorkspaceBaselineAuthority, ): WorkspaceEpochProjectionRow { diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index 71839303b2..eea1b74c3b 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -29,10 +29,22 @@ export interface WorkspaceSuccessorCommitResult { head: WorkspaceHeadRecordV1; outcomeRuntimeEventSeq: number; } +export interface ManagedMutationTerminalCommitInput { + readonly reason: 'operation_failed_no_effect' | 'no_workspace_change'; + readonly toolOutcome: WorkspaceSuccessorCommitInput['toolOutcome']; +} +export interface ManagedMutationTerminalCommitResult { + readonly created: boolean; + readonly outcomeRuntimeEventSeq: number; +} type WorkspaceSuccessorAuthorityWriter = ( input: WorkspaceSuccessorCommitInput, rootId: string, ) => Promise; +type ManagedMutationTerminalWriter = ( + input: ManagedMutationTerminalCommitInput, + rootId: string, +) => Promise; type WorkspaceHeadReader = ( workspaceId: string, workspaceEpochId: string, @@ -60,6 +72,7 @@ type ManagedMutationReservationReader = ( interface WorkspaceBaselineAuthorityRegistration { readonly writer: WorkspaceBaselineAuthorityWriter; readonly successorWriter: WorkspaceSuccessorAuthorityWriter; + readonly terminalWriter: ManagedMutationTerminalWriter; readonly readHead: WorkspaceHeadReader; readonly readActiveManagedMutation: ManagedMutationReservationReader; readonly bindStorageRoot: WorkspaceStorageRootBinder; @@ -78,6 +91,7 @@ export function registerWorkspaceBaselineAuthorityWriterInternal( databasePath: string, writer: WorkspaceBaselineAuthorityWriter, successorWriter: WorkspaceSuccessorAuthorityWriter, + terminalWriter: ManagedMutationTerminalWriter, bindStorageRoot: WorkspaceStorageRootBinder, readHead: WorkspaceHeadReader, readActiveManagedMutation: ManagedMutationReservationReader, @@ -89,6 +103,7 @@ export function registerWorkspaceBaselineAuthorityWriterInternal( workspaceBaselineAuthorityWriters.set(store, { writer, successorWriter, + terminalWriter, readHead, readActiveManagedMutation, bindStorageRoot, @@ -146,6 +161,18 @@ export function commitWorkspaceSuccessorInternal( return registration.successorWriter(input, registration.boundRootId); } +export function commitManagedMutationTerminalInternal( + store: object, + input: ManagedMutationTerminalCommitInput, +): Promise { + const registration = workspaceBaselineAuthorityWriters.get(store); + if (!registration) throw new Error('Managed mutation terminal authority writer is unavailable'); + if (!registration.boundRootId) { + throw new Error('Workspace successor authority store has no durable storage-root binding'); + } + return registration.terminalWriter(input, registration.boundRootId); +} + export function bindWorkspaceBaselineAuthorityStoreRootInternal( store: object, rootId: string, diff --git a/scripts/recovery-test-inventory.mjs b/scripts/recovery-test-inventory.mjs index 925533ad79..1f2c890c26 100644 --- a/scripts/recovery-test-inventory.mjs +++ b/scripts/recovery-test-inventory.mjs @@ -21,6 +21,7 @@ export const RECOVERY_TEST_INVENTORIES = Object.freeze({ 'packages/storage/src/__tests__/managed-workspace-baseline.test.ts', 'packages/storage/src/__tests__/git-workspace-service.test.ts', 'packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts', + 'packages/storage/src/__tests__/managed-workspace-owner.test.ts', 'packages/storage/src/__tests__/sqlite-runtime-crash.test.ts', 'packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts', 'packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts', @@ -32,13 +33,14 @@ export const RECOVERY_TEST_INVENTORIES = Object.freeze({ 'packages/storage/dist/__tests__/managed-workspace-baseline.test.js', 'packages/storage/dist/__tests__/git-workspace-service.test.js', 'packages/storage/dist/__tests__/managed-mutation-candidate-authority.test.js', + 'packages/storage/dist/__tests__/managed-workspace-owner.test.js', 'packages/storage/dist/__tests__/sqlite-runtime-crash.test.js', 'packages/storage/dist/__tests__/sqlite-recovery-concurrency.test.js', ]), expectedByPlatform: Object.freeze({ - darwin: Object.freeze({ tests: 28, pass: 28, skipped: 0 }), - linux: Object.freeze({ tests: 28, pass: 28, skipped: 0 }), - win32: Object.freeze({ tests: 28, pass: 28, skipped: 0 }), + darwin: Object.freeze({ tests: 29, pass: 29, skipped: 0 }), + linux: Object.freeze({ tests: 29, pass: 29, skipped: 0 }), + win32: Object.freeze({ tests: 29, pass: 29, skipped: 0 }), }), }), }); From ef707e093525563d613573c7acde99a0faeef463 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Tue, 18 Aug 2026 20:19:55 +0800 Subject: [PATCH 2/9] feat(runtime-host): compose managed Write and Edit --- ...orkspace-write-edit-production-v1.zh-CN.md | 95 ++++++++++++ ...hase4-workspace-checkpoint-design.zh-CN.md | 3 +- packages/core/src/session.ts | 2 +- .../hosted-execution-tool-profile.test.ts | 30 ++++ ...managed-workspace-mutation-session.test.ts | 109 ++++++++++++++ .../workspace-execution-composition.test.ts | 14 ++ .../src/server/execution-composition.ts | 61 +++++++- .../src/server/execution-model-composition.ts | 4 +- .../server/hosted-execution-tool-profile.ts | 46 ++++-- .../managed-workspace-mutation-session.ts | 137 ++++++++++++++++++ .../server/workspace-execution-composition.ts | 20 ++- packages/runtime/src/ai-sdk-backend.ts | 3 + .../runtime/src/filesystem-worker/index.ts | 2 + 13 files changed, 508 insertions(+), 18 deletions(-) create mode 100644 docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md create mode 100644 packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts create mode 100644 packages/runtime-host/src/server/managed-workspace-mutation-session.ts diff --git a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md new file mode 100644 index 0000000000..3ebbc3a873 --- /dev/null +++ b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md @@ -0,0 +1,95 @@ +# Managed Workspace Write/Edit Production Composition v1 + +- 阶段:M2.4 +- 状态:实现切片;首个生产消费者为显式 `managed-coding-v1` profile +- owner:Runtime Host composition + `ManagedWorkspaceOwner` + SQLite workspace authority +- 不包含:workspace-bound continuation(M3)、自动 restore/rebaseline/publish(M4) + +## 1. 主要不变量 + +真实 Write/Edit 只有在下面这条 owner 链完整成立时才可进入 managed durable mode: + +```text +explicit managed-coding-v1 profile + -> Host 打开同一个 owner-bound managed workspace execution handle + -> owner 从真实 filesystem worker permission profile + protocol 计算 profile digest + -> T1 原子持久化 call + dispatch + exact base/path/profile + exclusive reservation + -> worker 只在该 admission 的 mutation scope 内执行 + -> Git owner capture exact candidate + -> SQLite 原子提交 exact T2 + successor fact + canonical head,并释放 reservation + -> Git candidate 幂等 accept,将 worktree 投影到 SQLite accepted head +``` + +caller 不能提供 cwd、base head、candidate、execution profile digest 或 terminal result。普通 +`headless-coding-v1` 不会因为 bundled Git/worker 恰好可用而静默升级;只有显式 profile 在 T1 前选择 +`managed_mutation_v1`。缺失 owner、worker、Git、SQLite authority 或任一 identity mismatch 都在 T1 前拒绝。 + +首版 profile 的工具面严格限定为 `Read/Glob/Grep/Write/Edit`。`Bash` 与 `apply_patch` 尚无等价的 effect +owner 和 successor 协议,因此不会暴露给 managed task;不能借由这些工具绕过 canonical workspace head。 + +## 2. 唯一事实源和原子边界 + +| 事实/投影 | owner | 原子边界 | +|---|---|---| +| provider result | Tool Runtime | 一次 bounded strict-JSON snapshot;Host 只能看到 proof,不能重交 result | +| mutation reservation | SQLite workspace authority | 与 T1 call/dispatch 同一 transaction | +| accepted workspace head | immutable RuntimeEvents | exact T2 + successor fact + projection/head CAS + reservation release 同一 transaction | +| Git candidate | Git candidate owner | private index、candidate ref CAS、durable receipt | +| worktree 当前内容 | Git projection | 从 SQLite accepted head + durable candidate receipt 幂等重放;不是 accepted truth | + +SQLite successor 已提交而 Git accept 尚未完成时,系统已经拥有 canonical accepted truth。新进程重新打开 +workspace 时必须从 SQLite head 找到 operation-bound candidate,严格重验 commit/tree/path/profile 后再 accept;不得 +重新执行 Write/Edit。 + +## 3. 无副作用终态 + +失败 Write/Edit 与成功 no-op 不允许回退 generic T2。Owner 先证明 worktree 仍精确等于 T1 base,然后由专用 +SQLite writer 在一个 transaction 内提交: + +```text +exact Runtime-owned function_response ++ managed_mutation_terminal_v1 fact ++ exact reservation release ++ unchanged canonical workspace head +``` + +terminal fact 的 reason 只有: + +- `operation_failed_no_effect`:response 必须为 error; +- `no_workspace_change`:response 必须为 success。 + +terminal fact、T1 mutation identity、dispatch/outcome identity、base head 与 expected paths 在 online writer 和 rebuild +中都要一致。generic append/import 和 generic T2 writer 无权写入该 fact。若 worker 可能已改变文件、Git 状态漂移、 +terminal fact 缺失/损坏或 candidate 无法重验,则保留 reservation 并 fail-stop/park。 + +## 4. 失败状态与回滚 + +| 崩溃/失败点 | durable 状态 | 重启行为 | +|---|---|---| +| T1 前 | 无 reservation | 明确失败,可重新 admission | +| T1 后、worker 前/中,effect 不可证明 | reservation 保留 | park;M2.4 不自动猜测或覆盖 | +| worker error 且 Git owner 证明 clean base | error T2 + terminal fact | 已收敛,head 不变 | +| success 但 tree 无变化 | success T2 + terminal fact | 已收敛,head 不变 | +| candidate capture 后、SQLite commit 前 | T1 + candidate artifact | reservation 保留;不得对外宣称成功 | +| SQLite successor commit 后、Git accept 前 | accepted successor + candidate receipt | reopen 幂等 accept,不重跑工具 | +| Git accept 后、provider publication 前 | accepted successor | Runtime 采用 exact durable outcome;后续 replay 同值 | +| 外部修改或 evidence mismatch | 不推进/不覆盖 | quarantine 或 park | + +## 5. 平台能力矩阵 + +| 能力 | Linux | macOS | Windows | +|---|---|---|---| +| T1/reservation/terminal/successor SQLite 原子性 | 承诺 | 承诺 | 承诺 | +| exact Write/Edit path + worker profile binding | 承诺 | 承诺 | 承诺 | +| candidate capture/accept process-crash 收敛 | CI 证明 | CI 证明 | CI 证明 | +| successor commit 后进程 kill、reopen 不重跑 | CI 证明 | CI 证明 | CI 证明 | +| power-loss 后硬件永久写入顺序 | 不承诺 | 不承诺 | 不承诺 | + +统一 recovery inventory 执行真实 child process kill/reopen。这里的承诺是 process-crash convergence,不把普通 +`fsync`、Git ref 或 SQLite WAL 夸大为断电级证明。 + +## 6. 用户可见边界 + +M2.4 完成的是“显式 managed coding task 中,一次 Write/Edit 的执行与 accepted workspace version 原子闭环”。 +它还不是“任意中断点自动继续整段对话”:把 continuation cursor 与 accepted workspace version 绑定、在重启后继续 +provider loop 属于 M3。attached checkout 仍保持原能力,不会自动获得 managed redo/restore。 diff --git a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md index c836d251a8..400301ff02 100644 --- a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md +++ b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md @@ -605,7 +605,8 @@ M2 按 owner 与原子边界拆成五个 stacked slices: size/envelope validation 与 publication failure 都 fail-stop,禁止 generic T2; 5. **M2.4 Write/Edit production composition**:由同一 owner 绑定真实 mutation profile、canonical head、 active reservation、worker、candidate capture 与 M2.1 bundle,并用真实 Host kill/reopen crash test 完成前三片的 - 生产消费者。 + 生产消费者。实现合同见 + [Managed Workspace Write/Edit Production Composition v1](./runtime-managed-workspace-write-edit-production-v1.zh-CN.md)。 M2.2/M2.3a/M2.3b 在 M2.4 消费者存在前保持 Draft。M2.4 不绑定 continuation boundary;该能力仍属于 M3。 diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 52b966b2a8..98aee59552 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -173,7 +173,7 @@ export function isTurnStatus(value: unknown): value is TurnStatus { // Header (JSONL line 1) // ============================================================================ -export const SESSION_TOOL_PROFILES = ['headless-coding-v1'] as const; +export const SESSION_TOOL_PROFILES = ['headless-coding-v1', 'managed-coding-v1'] as const; export type SessionToolProfile = (typeof SESSION_TOOL_PROFILES)[number]; export function isSessionToolProfile(value: unknown): value is SessionToolProfile { diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts index 68c0133d76..b3c553a5a7 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts @@ -72,3 +72,33 @@ test('the headless coding profile freezes prompt, tools, memory, and foreground ); assert.equal((await schema.safeParseAsync({ command: 'true', pty: true })).success, false); }); + +test('the managed coding profile fixes Write/Edit durable mode before T1', () => { + const tool = (name: 'Write' | 'Edit'): MakaTool => ({ + name, + description: name, + parameters: z.object({}), + recoveryMode: 'reconcile', + impl: async () => 'ok', + }); + const attached = projectHostedExecutionTools([tool('Write')], 'headless-coding-v1'); + const managed = projectHostedExecutionTools([tool('Write'), tool('Edit')], 'managed-coding-v1'); + + assert.equal(attached[0]?.durableExecutionProfile, undefined); + assert.deepEqual(hostedExecutionRunProfile('managed-coding-v1')?.toolNames, [ + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + ]); + assert.doesNotMatch(hostedExecutionRunProfile('managed-coding-v1')!.systemPrompt, /Bash/u); + assert.deepEqual( + managed.map((entry) => [entry.name, entry.durableExecutionProfile]), + [ + ['Write', 'managed_mutation_v1'], + ['Edit', 'managed_mutation_v1'], + ], + ); + assert.equal(hostedExecutionRunProfile('managed-coding-v1')?.memoryExtraction, false); +}); diff --git a/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts b/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts new file mode 100644 index 0000000000..e2d255e299 --- /dev/null +++ b/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import type { InteractiveExecutionStoresWriter } from '@maka/storage/execution-stores'; +import type { + ManagedWorkspaceExecutionHandle, + ManagedWorkspaceOwner, +} from '@maka/storage/managed-workspace-owner'; +import { createManagedWorkspaceMutationSession } from '../server/managed-workspace-mutation-session.js'; + +test('binds read and mutation operations to one owner-issued managed handle', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-managed-mutation-session-')); + const handle = Object.freeze({ + kind: 'managed_workspace_execution_handle_v1' as const, + }) as ManagedWorkspaceExecutionHandle; + const calls: string[] = []; + const owner = { + state: 'ready', + async openManagedWorkspaceBaselineFromExecutionStores(_stores, input) { + calls.push(`open:${input.sourceRoot}`); + return { + created: true, + head: { + repositoryId: input.repositoryId, + workspaceId: input.workspaceId, + workspaceEpochId: input.workspaceEpochId, + workspaceVersionId: 'version_1', + acceptedEventId: 'accepted_1', + commitOid: 'a'.repeat(40), + treeOid: 'b'.repeat(40), + revision: 1, + }, + executionHandle: handle, + }; + }, + async withManagedWorkspaceExecution(seenHandle, operation) { + assert.equal(seenHandle, handle); + calls.push('read-scope'); + return await operation({ kind: 'managed_workspace_execution_scope_v1' }); + }, + async executeReadOnlyFilesystemOperation(_scope, operation) { + calls.push(`read:${operation.kind}`); + return { kind: 'read' as const, content: 'managed' }; + }, + async admitManagedWorkspaceMutation(seenHandle, input) { + assert.equal(seenHandle, handle); + calls.push(`admit:${input.toolName}`); + return { + durableDispatch: {} as never, + async execute() { + throw new Error('not used'); + }, + async dispose() {}, + }; + }, + async executeManagedMutationFilesystemOperation(operation) { + calls.push(`mutation:${operation.kind}`); + return { + kind: 'write' as const, + ok: true as const, + path: operation.path, + bytes: 7, + }; + }, + async openManagedWorkspaceBaseline() { + throw new Error('public raw store seam must not be used by Runtime Host'); + }, + async close() {}, + } satisfies ManagedWorkspaceOwner; + try { + const session = await createManagedWorkspaceMutationSession({ + owner, + stores: {} as InteractiveExecutionStoresWriter, + sourceRoot: root, + sessionId: 'managed-session', + }); + assert.equal( + ( + await session.filesystemWorker.execute({ + operation: { kind: 'read', path: 'README.md' }, + cwd: 'caller-controlled-cwd', + }) + ).kind, + 'read', + ); + assert.equal( + ( + await session.filesystemWorker.execute({ + operation: { kind: 'write', path: 'tracked.txt', content: 'managed' }, + cwd: 'caller-controlled-cwd', + }) + ).kind, + 'write', + ); + await session.admitManagedMutation({ + operationId: 'operation-managed-session', + toolName: 'Write', + persistedArgs: { path: 'tracked.txt', content: 'managed' }, + abortSignal: new AbortController().signal, + }); + + assert.deepEqual(calls.slice(1), ['read-scope', 'read:read', 'mutation:write', 'admit:Write']); + assert.equal(calls[0], `open:${await realpath(root)}`); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts b/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts index 8983e21841..a566fc8b72 100644 --- a/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/workspace-execution-composition.test.ts @@ -12,6 +12,8 @@ import { RuntimeHostWorkspaceExecutionError, } from '../server/workspace-execution-composition.js'; +const TEST_MUTATION_PROFILE = `sha256:${'a'.repeat(64)}` as const; + test('keeps attached and managed execution profiles explicit', async () => { const calls: string[] = []; const handle = Object.freeze({ kind: 'managed_workspace_execution_handle_v1' as const }); @@ -19,6 +21,7 @@ test('keeps attached and managed execution profiles explicit', async () => { const managedOwner = fakeManagedOwner({ handle, scope, calls }); const composition = createRuntimeHostWorkspaceExecutionComposition({ filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, async execute(input) { calls.push(`worker:${input.cwd}:${input.operation.kind}`); return { kind: 'read', content: 'attached' }; @@ -47,6 +50,7 @@ test('never falls back a managed profile to attached execution', async () => { let workerCalls = 0; const composition = createRuntimeHostWorkspaceExecutionComposition({ filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, async execute() { workerCalls += 1; return { kind: 'read', content: 'unsafe fallback' }; @@ -71,6 +75,7 @@ test('rejects forged or malformed profiles before worker dispatch', async () => let workerCalls = 0; const composition = createRuntimeHostWorkspaceExecutionComposition({ filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, async execute() { workerCalls += 1; return { kind: 'read', content: 'unsafe' }; @@ -137,6 +142,12 @@ function fakeManagedOwner(input: { async openManagedWorkspaceBaseline() { throw new Error('not used'); }, + async openManagedWorkspaceBaselineFromExecutionStores() { + throw new Error('not used'); + }, + async admitManagedWorkspaceMutation() { + throw new Error('not used'); + }, async withManagedWorkspaceExecution(handle, operation) { assert.equal(handle, input.handle); input.calls.push('managed:admit'); @@ -148,6 +159,9 @@ function fakeManagedOwner(input: { await input.workerBlocked; return { kind: 'read', content: 'managed' }; }, + async executeManagedMutationFilesystemOperation() { + throw new Error('not used'); + }, async close() { input.calls.push('managed:close'); }, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index c2ca7fea76..04d63a0586 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -3,6 +3,7 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; import { emptyPlanSessionState } from '@maka/core/plan'; import type { PermissionMode } from '@maka/core/permission'; +import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { isDeepResearchSession } from '@maka/core/session'; import { filterModelVisibleTaskLedgerTasks } from '@maka/core/task-ledger'; import { AgentGraphCoordinator } from '@maka/runtime/stream-graph-coordinator'; @@ -22,6 +23,7 @@ import { } from '@maka/runtime/sandbox'; import { createFilesystemWorkerLaunchSpecProvider, + FILESYSTEM_WORKER_PROTOCOL_VERSION, FilesystemWorkerClient, } from '@maka/runtime/filesystem-worker'; import { FakeBackend } from '@maka/runtime/fake-backend'; @@ -173,6 +175,7 @@ import { } from './web-search-tool.js'; import { createHostWebFetchService, createHostWebFetchToolFromService } from './web-fetch-tool.js'; import { createHostExecutionArtifactServices } from './execution-artifacts.js'; +import { createManagedWorkspaceMutationSession } from './managed-workspace-mutation-session.js'; import { createRuntimeHostWorkspaceExecutionComposition, RuntimeHostWorkspaceExecutionError, @@ -638,8 +641,23 @@ export async function createExecutionRuntimeHostComposition( backends.register( 'ai-sdk', dependencies.primaryBackendFactory ?? - ((backendContext) => - createHostAiSdkBackend({ + (async (backendContext) => { + const managedMutationSession = + backendContext.header.toolProfile === 'managed-coding-v1' + ? await createManagedWorkspaceMutationSession({ + owner: requireManagedWorkspaceOwner(managedWorkspaceOwner), + stores, + sourceRoot: backendContext.header.cwd, + sessionId: backendContext.sessionId, + ...(backendContext.abortSignal + ? { abortSignal: backendContext.abortSignal } + : {}), + }) + : undefined; + const sessionBuiltinTools = managedMutationSession + ? { ...builtinTools, filesystemWorker: managedMutationSession.filesystemWorker } + : builtinTools; + return createHostAiSdkBackend({ context: backendContext, runtimePolicy: runtimePolicyStores, oauthCredentials, @@ -657,7 +675,7 @@ export async function createExecutionRuntimeHostComposition( backendContext.sessionId, ), goalTools: requireGoal(goal).tools, - builtinTools, + builtinTools: sessionBuiltinTools, hostTools, resolveRootTools: (sessionId) => requireGraphCoordinator(graphCoordinator).toolsForSession(sessionId), @@ -677,8 +695,12 @@ export async function createExecutionRuntimeHostComposition( backendContext.sessionId, ), runtimeCommitSink: stores.runtimeEventStore, + ...(managedMutationSession + ? { admitManagedMutation: managedMutationSession.admitManagedMutation } + : {}), requestDrain: context.requestDrain, - })), + }); + }), ); const runtimeAuthority: RuntimeHostedRootAuthority = { bindRun: (identity) => messages.bindRun(identity), @@ -1727,28 +1749,57 @@ function requireWorkspaceExecution( return composition; } +function requireManagedWorkspaceOwner( + owner: ManagedWorkspaceOwner | undefined, +): ManagedWorkspaceOwner { + if (!owner) { + throw new RuntimeHostWorkspaceExecutionError( + 'managed_workspace_profile_unavailable', + 'Managed coding requires the bundled Git workspace owner', + ); + } + return owner; +} + function adaptManagedWorkspaceFilesystemWorker( worker: Pick, ): ManagedWorkspaceFilesystemWorker { return { + mutationExecutionProfileDigest: managedMutationWorkerProfileDigest(), async execute(input) { const result = await worker.execute(input); switch (result.kind) { case 'read': case 'read_image': + case 'write': + case 'edit': case 'glob': case 'grep': return result; default: throw new RuntimeHostWorkspaceExecutionError( 'workspace_operation_denied', - `Read-only filesystem worker returned mutating result ${result.kind}`, + `Managed filesystem worker returned unsupported result ${result.kind}`, ); } }, }; } +function managedMutationWorkerProfileDigest(): `sha256:${string}` { + const digest = createHash('sha256') + .update( + JSON.stringify({ + protocol: 'maka_managed_workspace_mutation_worker_profile_v1', + permissionProfile: createWorkspaceWritePermissionProfile(), + workerProtocol: FILESYSTEM_WORKER_PROTOCOL_VERSION, + }), + 'utf8', + ) + .digest('hex'); + return `sha256:${digest}`; +} + function subagentWritebackArtifactId(sessionId: string, turnId: string): string { const digest = createHash('sha256') .update('maka-subagent-writeback-v1\0') diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index c9604bd6b0..0a136319e2 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -5,7 +5,7 @@ import { relayModelProfile } from '@maka/core/model-thinking'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import type { ModelCallCommit } from '@maka/core/agent-run'; import type { PermissionMode } from '@maka/core/permission'; -import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; +import { AiSdkBackend, type AiSdkBackendInput } from '@maka/runtime/ai-sdk-backend'; import { buildDefaultContextBudgetPolicy, resolveSelectedModelContextWindow, @@ -54,6 +54,7 @@ export interface HostAiSdkBackendInput { readonly usage: HostExecutionUsageAuthority; readonly requestDrain: () => void; readonly runtimeCommitSink?: RuntimeCommitSink; + readonly admitManagedMutation?: AiSdkBackendInput['admitManagedMutation']; readonly childAgents?: HostChildAgentBackendCapabilities; readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; } @@ -412,6 +413,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom assertModelCallAccountingReady, recordToolInvocation: (event) => recordToolInvocation({ repo: telemetry }, event), ...(input.runtimeCommitSink ? { runtimeCommitSink: input.runtimeCommitSink } : {}), + ...(input.admitManagedMutation ? { admitManagedMutation: input.admitManagedMutation } : {}), ...(providerRequestCapture ? { recordProviderRequestCapture: providerRequestCapture, diff --git a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts index 221ad295dd..836e14a7d5 100644 --- a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts +++ b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts @@ -12,6 +12,11 @@ const HEADLESS_CODING_V1_TOOL_NAMES = [ 'apply_patch', ] as const; +// M2.4 exposes only operations whose workspace effects are owned by the +// managed read bridge or the Write/Edit successor protocol. Bash and +// apply_patch remain unavailable until they have an equivalent effect owner. +const MANAGED_CODING_V1_TOOL_NAMES = ['Read', 'Write', 'Edit', 'Glob', 'Grep'] as const; + const HEADLESS_CODING_V1_SYSTEM_PROMPT = [ 'Complete the task by acting with the available tools, not by narrating.', 'Prefer Read, Glob, and Grep for inspection, Edit and Write for file changes, and Bash for shell commands and tests.', @@ -19,6 +24,14 @@ const HEADLESS_CODING_V1_SYSTEM_PROMPT = [ 'Stop when the task is complete.', ].join('\n'); +const MANAGED_CODING_V1_SYSTEM_PROMPT = [ + 'Complete the task by acting with the available tools, not by narrating.', + 'Use Read, Glob, and Grep for inspection, and Edit or Write for file changes.', + 'All project operations run in a Maka-owned isolated Git workspace.', + 'Shell commands and patch tools are unavailable in this execution profile.', + 'Stop when the requested file changes are complete.', +].join('\n'); + const HEADLESS_CODING_V1_BASH_DESCRIPTION = 'Run a foreground shell command in the session cwd. Use Bash for inspection, builds, tests, and task-local generation. Background execution and PTY sessions are unavailable in this profile.'; @@ -46,6 +59,13 @@ export function hostedExecutionRunProfile( memoryExtraction: false, }; } + if (profile === 'managed-coding-v1') { + return { + toolNames: MANAGED_CODING_V1_TOOL_NAMES, + systemPrompt: MANAGED_CODING_V1_SYSTEM_PROMPT, + memoryExtraction: false, + }; + } profile satisfies never; throw new Error('Unknown Session tool profile'); } @@ -60,13 +80,21 @@ export function projectHostedExecutionTools( ): readonly MakaTool[] { if (profile === undefined) return tools; hostedExecutionRunProfile(profile); - return tools.map((tool) => - tool.name === 'Bash' - ? { - ...tool, - description: HEADLESS_CODING_V1_BASH_DESCRIPTION, - parameters: HEADLESS_CODING_V1_BASH_PARAMETERS, - } - : tool, - ); + return tools.map((tool) => { + if (tool.name === 'Bash') { + return { + ...tool, + description: HEADLESS_CODING_V1_BASH_DESCRIPTION, + parameters: HEADLESS_CODING_V1_BASH_PARAMETERS, + }; + } + if (profile === 'managed-coding-v1' && (tool.name === 'Write' || tool.name === 'Edit')) { + return { + ...tool, + recoveryMode: 'reconcile' as const, + durableExecutionProfile: 'managed_mutation_v1' as const, + }; + } + return tool; + }); } diff --git a/packages/runtime-host/src/server/managed-workspace-mutation-session.ts b/packages/runtime-host/src/server/managed-workspace-mutation-session.ts new file mode 100644 index 0000000000..e08bc52cb3 --- /dev/null +++ b/packages/runtime-host/src/server/managed-workspace-mutation-session.ts @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { AiSdkBackendInput } from '@maka/runtime/ai-sdk-backend'; +import type { + FilesystemWorkerClient, + FilesystemWorkerClientOperation, + FilesystemWorkerExecuteInput, + FilesystemWorkerResult, +} from '@maka/runtime/filesystem-worker'; +import type { + ManagedWorkspaceOwner, + ManagedWorkspaceReadOnlyOperation, +} from '@maka/storage/managed-workspace-owner'; +import type { InteractiveExecutionStoresWriter } from '@maka/storage/execution-stores'; + +export interface ManagedWorkspaceMutationSession { + readonly filesystemWorker: { + execute(input: FilesystemWorkerExecuteInput): ReturnType; + }; + readonly admitManagedMutation: NonNullable; +} + +export interface CreateManagedWorkspaceMutationSessionInput { + readonly owner: ManagedWorkspaceOwner; + readonly stores: InteractiveExecutionStoresWriter; + readonly sourceRoot: string; + readonly sessionId: string; + readonly abortSignal?: AbortSignal; +} + +type ManagedMutationAdmissionInput = Parameters< + NonNullable +>[0]; + +/** + * Binds one hosted coding session to one owner-issued managed workspace handle. + * The model never supplies a cwd, workspace identity, profile digest, or Git + * candidate identity for this path. + */ +export async function createManagedWorkspaceMutationSession( + input: CreateManagedWorkspaceMutationSessionInput, +): Promise { + input.abortSignal?.throwIfAborted(); + const sourceRoot = await realpath(input.sourceRoot); + input.abortSignal?.throwIfAborted(); + const identity = managedMutationIdentity(sourceRoot, input.sessionId); + const opened = await input.owner.openManagedWorkspaceBaselineFromExecutionStores(input.stores, { + ...identity, + sourceRoot, + }); + input.abortSignal?.throwIfAborted(); + + return Object.freeze({ + filesystemWorker: Object.freeze({ + async execute(workerInput: FilesystemWorkerExecuteInput) { + const { operation } = workerInput; + if (operation.kind === 'write' || operation.kind === 'edit') { + return normalizeWorkerResult( + await input.owner.executeManagedMutationFilesystemOperation( + operation, + workerInput.abortSignal, + ), + sourceRoot, + operation.path, + ); + } + if (!isManagedReadOnlyOperation(operation)) { + throw new Error(`Managed workspace operation is not admitted: ${operation.kind}`); + } + return normalizeWorkerResult( + await input.owner.withManagedWorkspaceExecution(opened.executionHandle, (scope) => + input.owner.executeReadOnlyFilesystemOperation( + scope, + operation, + workerInput.abortSignal, + ), + ), + ); + }, + }), + admitManagedMutation: async (admissionInput: ManagedMutationAdmissionInput) => { + if (admissionInput.toolName !== 'Write' && admissionInput.toolName !== 'Edit') { + throw new Error(`Managed mutation tool is not admitted: ${admissionInput.toolName}`); + } + return await input.owner.admitManagedWorkspaceMutation(opened.executionHandle, { + ...admissionInput, + toolName: admissionInput.toolName, + }); + }, + }); +} + +function isManagedReadOnlyOperation( + operation: FilesystemWorkerClientOperation, +): operation is ManagedWorkspaceReadOnlyOperation { + return operation.kind === 'read' || operation.kind === 'glob' || operation.kind === 'grep'; +} + +function normalizeWorkerResult( + result: Awaited< + ReturnType< + | ManagedWorkspaceOwner['executeManagedMutationFilesystemOperation'] + | ManagedWorkspaceOwner['executeReadOnlyFilesystemOperation'] + > + >, + logicalRoot?: string, + logicalPath?: string, +): FilesystemWorkerResult { + if (result.kind === 'glob') return { kind: 'glob', files: [...result.files] }; + if (result.kind === 'grep') return { kind: 'grep', matches: [...result.matches] }; + if ((result.kind === 'write' || result.kind === 'edit') && logicalRoot && logicalPath) { + return { ...result, path: resolve(logicalRoot, logicalPath) }; + } + return { ...result }; +} + +function managedMutationIdentity(sourceRoot: string, sessionId: string) { + const sourceIdentity = domainDigest('source', sourceRoot); + const sessionIdentity = domainDigest('session', sourceRoot, sessionId); + return Object.freeze({ + repositoryId: `repository_${sourceIdentity}`, + workspaceId: `workspace_${sessionIdentity}`, + workspaceEpochId: `epoch_${domainDigest('epoch', sourceRoot, sessionId)}`, + workspaceInstanceId: `instance_${domainDigest('instance', sourceRoot, sessionId)}`, + }); +} + +function domainDigest(domain: string, ...values: readonly string[]): string { + const hash = createHash('sha256'); + hash.update(`maka-managed-mutation-${domain}-v1\0`, 'utf8'); + for (const value of values) { + hash.update(value, 'utf8'); + hash.update('\0', 'utf8'); + } + return hash.digest('hex').slice(0, 32); +} diff --git a/packages/runtime-host/src/server/workspace-execution-composition.ts b/packages/runtime-host/src/server/workspace-execution-composition.ts index 53d80b867d..96f1b4904b 100644 --- a/packages/runtime-host/src/server/workspace-execution-composition.ts +++ b/packages/runtime-host/src/server/workspace-execution-composition.ts @@ -134,12 +134,19 @@ export function createRuntimeHostWorkspaceExecutionComposition( 'Attached workspace filesystem worker is unavailable', ); } - return await input.filesystemWorker.execute({ + const result = await input.filesystemWorker.execute({ operation, cwd: profile.cwd, executionBoundary: createManagedExecutionBoundary(createReadOnlyPermissionProfile(), 0), ...(abortSignal ? { abortSignal } : {}), }); + if (!isReadOnlyResult(result)) { + throw new RuntimeHostWorkspaceExecutionError( + 'workspace_operation_denied', + `Read-only execution returned mutating result ${result.kind}`, + ); + } + return result; } finally { finishOperation(); } @@ -163,6 +170,17 @@ function isReadOnlyOperation(input: unknown): input is ManagedWorkspaceReadOnlyO return kind === 'read' || kind === 'glob' || kind === 'grep'; } +function isReadOnlyResult( + input: Awaited>, +): input is ManagedWorkspaceReadOnlyResult { + return ( + input.kind === 'read' || + input.kind === 'read_image' || + input.kind === 'glob' || + input.kind === 'grep' + ); +} + function isWorkspaceExecutionProfile( input: unknown, ): input is RuntimeHostWorkspaceExecutionProfile { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 316329dbd5..5dcbf9c4d0 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -758,6 +758,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { recordToolInvocation?: ToolTelemetryRecorder; /** Optional Phase 2 SQLite T1/T2 boundary for real tool execution. */ runtimeCommitSink?: RuntimeCommitSink; + /** Owner-bound managed Write/Edit admission for an isolated execution profile. */ + admitManagedMutation?: ToolRuntimeInput['admitManagedMutation']; /** Durable session-lifetime cumulative usage checkpoint after each completed provider step. */ recordUsageCheckpoint?: ( usage: NormalizedAiSdkUsage & { costUsd?: number }, @@ -1337,6 +1339,7 @@ export class AiSdkBackend implements AgentBackend { getRunTrace: () => identity.scope().runTrace, recordToolInvocation: input.recordToolInvocation, runtimeCommitSink: input.runtimeCommitSink, + admitManagedMutation: input.admitManagedMutation, recordToolArtifacts: input.recordToolArtifacts, }); } diff --git a/packages/runtime/src/filesystem-worker/index.ts b/packages/runtime/src/filesystem-worker/index.ts index 2365ae269f..d54ffe1e2b 100644 --- a/packages/runtime/src/filesystem-worker/index.ts +++ b/packages/runtime/src/filesystem-worker/index.ts @@ -7,6 +7,8 @@ export type { FilesystemWorkerClientOperation, FilesystemWorkerExecuteInput, } from './client.js'; +export type { FilesystemWorkerResult } from './protocol.js'; +export { FILESYSTEM_WORKER_PROTOCOL_VERSION } from './protocol.js'; export { buildFilesystemWorkerEnv, createFilesystemWorkerLaunchSpecProvider, From 298ff9d4ceb91a1d84dba3b149380726638c6e2c Mon Sep 17 00:00:00 2001 From: zhiiw Date: Tue, 18 Aug 2026 21:18:30 +0800 Subject: [PATCH 3/9] fix(runtime): close managed mutation settlement states --- .github/workflows/macos-recovery.yml | 2 + ...e-mutation-execution-admission-v1.zh-CN.md | 29 +- ...workspace-mutation-reservation-v1.zh-CN.md | 3 +- ...orkspace-write-edit-production-v1.zh-CN.md | 32 +- packages/core/src/runtime-event.ts | 17 +- ...managed-workspace-write-edit-host-crash.ts | 74 +++ ...managed-workspace-mutation-session.test.ts | 1 + ...managed-workspace-write-edit-crash.test.ts | 522 ++++++++++++++++++ .../src/server/execution-composition.ts | 8 + .../runtime-event-read-model.test.ts | 3 +- .../tool-runtime-durable-boundary.test.ts | 158 +++++- packages/runtime/src/tool-runtime.ts | 71 ++- .../__tests__/managed-workspace-owner.test.ts | 32 +- ...pace-version-authority-persistence.test.ts | 3 +- .../storage/src/managed-workspace-owner.ts | 37 +- packages/storage/src/sqlite-runtime-store.ts | 18 +- .../workspace-version-authority-internal.ts | 2 +- scripts/recovery-test-inventory.mjs | 15 +- 18 files changed, 936 insertions(+), 91 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/fixtures/managed-workspace-write-edit-host-crash.ts create mode 100644 packages/runtime-host/src/__tests__/managed-workspace-write-edit-crash.test.ts diff --git a/.github/workflows/macos-recovery.yml b/.github/workflows/macos-recovery.yml index c30604c5e8..c1f5cad5c5 100644 --- a/.github/workflows/macos-recovery.yml +++ b/.github/workflows/macos-recovery.yml @@ -34,6 +34,8 @@ jobs: run: | npm run build -w @maka/core npm run build -w @maka/storage + npm run build -w @maka/runtime + npm run build -w @maka/runtime-host - name: Verify managed workspace crash recovery run: node scripts/run-recovery-test-inventory.mjs managed-workspace diff --git a/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md index b17c508c84..c2cbb2fd5b 100644 --- a/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md @@ -1,7 +1,7 @@ # Managed Workspace Mutation Runtime Settlement v1 - 阶段:M2.3b -- 状态:实现切片;M2.4 已提供首个显式 managed Write/Edit 生产消费者 +- 状态:实现切片;M2.4 Runtime Host API 已消费,Desktop/CLI session creator 尚未接入 - owner:Tool Runtime managed settlement seam - durable 真相:M2.3a SQLite reservation 与 owner 已提交的 immutable outcome @@ -16,11 +16,12 @@ owner 最多调用一次;`execute()` 返回或抛错时 capability 立即关 Runtime 必须先 join 该 operation,再将整个结算判为 unsettled,确保 Runtime 返回后不存在仍可产生副作用的 detached execution。 -Runtime 只接受三种 owner 结算: +Runtime 只接受四种 owner 结算: 1. `workspace_successor_committed`:M2.1 的成功 T2、successor 与 head 已经原子提交; -2. `safely_discarded`:owner 已证明 workspace 无副作用,并已提交 exact error/no-op outcome 与 terminal fact; -3. `unsettled`:副作用或结算状态不可证明,M2.3a reservation 保留给恢复流程。 +2. `no_workspace_change_committed`:成功但 tree 未变化,success outcome 与 terminal fact 已提交; +3. `operation_failed_no_effect_committed`:失败且 owner 已证明 workspace 无副作用,error outcome 与 terminal fact 已提交; +4. `unsettled`:副作用或结算状态不可证明,M2.3a reservation 保留给恢复流程。 owner 返回值首先经过运行时结构校验,规范化成内部 terminal union。managed/generic lane 只由 T1 前已经确定的 `managedMutationAdmission` 决定,绝不再用 `durableOutcome` 是否 truthy 选择 writer;terminal settlement 缺失 @@ -66,13 +67,15 @@ flowchart TD Q --> N["normalize terminal proof"] N --> S{"terminal state proven?"} S -->|"successor committed"| A["compare exact durable success envelope"] - S -->|"safely discarded"| E["compare exact durable error envelope"] + S -->|"no change committed"| N0["compare exact durable success envelope"] + S -->|"failed no effect committed"| E["compare exact durable error envelope"] S -->|"unknown / throw / invalid"| P["fail-stop; reservation remains"] A --> R["publish provider result"] + N0 --> R E --> R ``` -`safely_discarded` 与 success 一样只携带 terminal proof 和 exact `durableOutcome`,不允许 Host 重新提交 +两个 no-effect committed 状态与 successor success 一样只携带 terminal proof 和 exact `durableOutcome`,不允许 Host 重新提交 `providerResult`。Runtime 在 operation 边界持有唯一 bounded strict-JSON snapshot;getter、serialization、 canonicalization 或 size-check 的任何失败都变成 managed unsettled,不写 generic T2。 @@ -96,7 +99,8 @@ parent refs 和 duration。缺字段、多字段或任意值不同都 fail-stop | Host admission seam 缺失或 admission 在 T1 前失败 | 无 T1、不执行工具 | | T1 commit 失败 | dispose admission,不执行工具 | | owner 已原子接受 successor | Runtime 采用 exact durable success outcome | -| owner 已安全 discard | Runtime 采用 exact durable error outcome | +| owner 已提交成功 no-op | Runtime 采用 exact durable success outcome | +| owner 已证明 operation failure 无副作用 | Runtime 采用 exact durable error outcome | | owner 返回 unsettled、抛错或响应丢失 | 无 generic T2、无 provider result,reservation 保留 | | owner 结算后再次调用 operation | capability 已关闭,不执行工具 | | owner 在 operation 运行中提前结算 | join operation 后 fail-stop,不接受 terminal settlement | @@ -110,11 +114,12 @@ parent refs 和 duration。缺字段、多字段或任意值不同都 fail-stop | T1 reservation kill/reopen | CI 证明 | CI 证明 | CI 证明 | | 跨进程唯一 mutation reservation | CI 证明 | CI 证明 | CI 证明 | | Runtime managed settlement fail-stop | 平台无关测试 | 平台无关测试 | 平台无关测试 | -| mutation worker/profile/candidate | CI 证明 | CI 证明 | CI 证明 | +| mutation worker/profile/candidate | CI 证明 | CI 证明 | 边界测试证明;完整 Host crash lane 等待发布 broker | | power-loss convergence | 不在本切片 | 不在本切片 | 不在本切片 | -统一 recovery inventory 同时包含 managed baseline/candidate、`sqlite-runtime-crash` 与 -`sqlite-recovery-concurrency`。Linux、macOS、Windows 使用相同文件清单、name pattern 和严格测试数量。 +统一 recovery inventory 同时包含 managed baseline/candidate、`sqlite-runtime-crash`、 +`sqlite-recovery-concurrency` 与真实 Host/worker kill-reopen。Linux、macOS 完整执行 30 条;Windows 使用同一清单, +其中 29 条通过,完整 Host/worker 用例因当前 runner 不打包 Rust broker 而明确 skip。 ## 7. 验证 @@ -125,8 +130,8 @@ parent refs 和 duration。缺字段、多字段或任意值不同都 fail-stop - terminal settlement 撤销 operation capability;detached operation 必须 join 后 fail-stop; - 工具事后修改原始 result 不得改变 durable、message、event 或 provider snapshot; - oversized result 必须在遍历越过 budget 前终止;strict-JSON 非法值不得进入 durable event; -- safe-discard live result 与 durable content 不一致时 fail-stop; -- getter/canonicalization 异常和超大 safe-discard 均不写 generic T2、不发布结果; +- no-effect terminal 的 live result 与 durable content 不一致时 fail-stop; +- getter/canonicalization 异常和超大 no-effect result 均不写 generic T2、不发布结果; - code-mode response 的 origin、hidden visibility、parent refs 与 duration 必须完整匹配; - explicit unsettled、owner throw 和 T1 后任意异常均 fail-stop; - real-process kill-after-T1 与双进程 reservation 竞争进入三平台 recovery inventory。 diff --git a/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md index 625537e90d..76f0a89c49 100644 --- a/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-mutation-reservation-v1.zh-CN.md @@ -73,7 +73,8 @@ successor,不重复推进 head。 | T1 后进程崩溃 | reservation 跨进程保留;新的 mutation 不能取得所有权 | | operation park | reservation 保留,禁止另一个 mutation 越过未知副作用 | -`safely_discarded` 的 canonical release fact 由 M2.4 定义为 `managed_mutation_terminal_v1`:只有 Git owner +`no_workspace_change_committed` 与 `operation_failed_no_effect_committed` 的 canonical release fact 由 M2.4 定义为 +`managed_mutation_terminal_v1`:只有 Git owner 证明 worktree 仍等于 exact T1 base 后,专用 SQLite writer 才能将 exact T2、terminal fact 与 reservation release 原子提交。M2.3a 仍不提供手工删除 reservation 的公共 API。 diff --git a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md index 3ebbc3a873..58ca8b51a2 100644 --- a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md @@ -1,7 +1,7 @@ # Managed Workspace Write/Edit Production Composition v1 - 阶段:M2.4 -- 状态:实现切片;首个生产消费者为显式 `managed-coding-v1` profile +- 状态:API-only Draft;Runtime Host 已消费显式 `managed-coding-v1` profile,但 Desktop/CLI 尚未创建该类 session - owner:Runtime Host composition + `ManagedWorkspaceOwner` + SQLite workspace authority - 不包含:workspace-bound continuation(M3)、自动 restore/rebaseline/publish(M4) @@ -43,7 +43,15 @@ workspace 时必须从 SQLite head 找到 operation-bound candidate,严格重 ## 3. 无副作用终态 -失败 Write/Edit 与成功 no-op 不允许回退 generic T2。Owner 先证明 worktree 仍精确等于 T1 base,然后由专用 +T1 后只允许进入四种互斥状态: + +- `workspace_successor_committed`:成功、有 workspace 变化,T2/successor/head/reservation 已原子收敛; +- `no_workspace_change_committed`:成功、无 workspace 变化,success T2/terminal/reservation 已原子收敛; +- `operation_failed_no_effect_committed`:失败、且 Git owner 已证明无副作用,error T2/terminal/reservation 已原子收敛; +- `unsettled`:副作用或 durable proof 不能证明,保留 reservation 并 fail-stop。 + +失败 Write/Edit 与成功 no-op 不允许回退 generic T2。真实 `FilesystemWorkerClient` 的确定性业务 reject 先由 +Runtime 捕获为唯一、不可变、bounded strict-JSON error proof;Owner 再证明 worktree 仍精确等于 T1 base。随后专用 SQLite writer 在一个 transaction 内提交: ```text @@ -53,10 +61,10 @@ exact Runtime-owned function_response + unchanged canonical workspace head ``` -terminal fact 的 reason 只有: +terminal fact 的 disposition 只有: -- `operation_failed_no_effect`:response 必须为 error; -- `no_workspace_change`:response 必须为 success。 +- `operation_failed_no_effect_committed`:response 必须为 error; +- `no_workspace_change_committed`:response 必须为 success。 terminal fact、T1 mutation identity、dispatch/outcome identity、base head 与 expected paths 在 online writer 和 rebuild 中都要一致。generic append/import 和 generic T2 writer 无权写入该 fact。若 worker 可能已改变文件、Git 状态漂移、 @@ -80,16 +88,20 @@ terminal fact 缺失/损坏或 candidate 无法重验,则保留 reservation | 能力 | Linux | macOS | Windows | |---|---|---|---| | T1/reservation/terminal/successor SQLite 原子性 | 承诺 | 承诺 | 承诺 | -| exact Write/Edit path + worker profile binding | 承诺 | 承诺 | 承诺 | +| exact Write/Edit path + worker profile binding | 承诺 | 承诺 | 实现并由边界测试证明;当前 recovery runner 未打包 broker | | candidate capture/accept process-crash 收敛 | CI 证明 | CI 证明 | CI 证明 | -| successor commit 后进程 kill、reopen 不重跑 | CI 证明 | CI 证明 | CI 证明 | +| 真实 Host/worker 在 successor commit 后 kill、reopen 不重跑 | CI 证明 | CI 证明 | release broker 存在;当前 recovery runner 明确 skip | | power-loss 后硬件永久写入顺序 | 不承诺 | 不承诺 | 不承诺 | -统一 recovery inventory 执行真实 child process kill/reopen。这里的承诺是 process-crash convergence,不把普通 -`fsync`、Git ref 或 SQLite WAL 夸大为断电级证明。 +统一 recovery inventory 执行真实 child process kill/reopen。Linux/macOS 的组合测试经过 Runtime Host、ToolRuntime、 +真实 `FilesystemWorkerClient`、Git owner 与 SQLite authority,并在 reopen 后执行一个真实 `edit_conflict`,证明 worker +reject 能收敛为 no-effect error terminal;Windows runner 仍执行 29 条 SQLite/Git crash 用例,但 +因没有构建发布包内的 Rust sandbox broker,完整 Host/worker 用例以一个显式 skip 记录,不能表述为已由该 lane 证明。 +这里的承诺是 process-crash convergence,不把普通 `fsync`、Git ref 或 SQLite WAL 夸大为断电级证明。 ## 6. 用户可见边界 M2.4 完成的是“显式 managed coding task 中,一次 Write/Edit 的执行与 accepted workspace version 原子闭环”。 -它还不是“任意中断点自动继续整段对话”:把 continuation cursor 与 accepted workspace version 绑定、在重启后继续 +当前入口是 Runtime Host API/profile,Desktop/CLI 还不会创建 `managed-coding-v1` session,因此本切片继续保持 Draft。 +它也还不是“任意中断点自动继续整段对话”:把 continuation cursor 与 accepted workspace version 绑定、在重启后继续 provider loop 属于 M3。attached checkout 仍保持原能力,不会自动获得 managed redo/restore。 diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 38215e1a89..e33affbf4b 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -263,8 +263,7 @@ export interface RuntimeEventManagedWorkspaceMutationV1 { */ export interface RuntimeEventManagedWorkspaceMutationTerminalV1 { protocol: 'managed_mutation_terminal_v1'; - disposition: 'safely_discarded'; - reason: 'operation_failed_no_effect' | 'no_workspace_change'; + disposition: 'operation_failed_no_effect_committed' | 'no_workspace_change_committed'; operationId: string; dispatchEventId: string; outcomeEventId: string; @@ -581,15 +580,7 @@ const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = ); const RUNTIME_MANAGED_WORKSPACE_MUTATION_TERMINAL_SHAPE = defineObjectShape()( - [ - 'protocol', - 'disposition', - 'reason', - 'operationId', - 'dispatchEventId', - 'outcomeEventId', - 'mutation', - ], + ['protocol', 'disposition', 'operationId', 'dispatchEventId', 'outcomeEventId', 'mutation'], [], ); const RUNTIME_PROTOCOL_MARKER_SHAPE = defineObjectShape()( @@ -913,8 +904,8 @@ export function isRuntimeManagedWorkspaceMutationTerminal( isRecord(value) && hasExactShape(value, RUNTIME_MANAGED_WORKSPACE_MUTATION_TERMINAL_SHAPE) && value.protocol === 'managed_mutation_terminal_v1' && - value.disposition === 'safely_discarded' && - (value.reason === 'operation_failed_no_effect' || value.reason === 'no_workspace_change') && + (value.disposition === 'operation_failed_no_effect_committed' || + value.disposition === 'no_workspace_change_committed') && typeof value.operationId === 'string' && value.operationId.length > 0 && typeof value.dispatchEventId === 'string' && diff --git a/packages/runtime-host/src/__tests__/fixtures/managed-workspace-write-edit-host-crash.ts b/packages/runtime-host/src/__tests__/fixtures/managed-workspace-write-edit-host-crash.ts new file mode 100644 index 0000000000..51dd548606 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/managed-workspace-write-edit-host-crash.ts @@ -0,0 +1,74 @@ +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { createExecutionRuntimeHostComposition } from '../../server/execution-composition.js'; + +const [rootPath, expectedRootId, gitExecutablePath, gitDigest, sessionId, turnId] = + process.argv.slice(2); +if ( + !rootPath || + !expectedRootId || + !gitExecutablePath || + !gitDigest?.startsWith('sha256:') || + !sessionId || + !turnId +) { + throw new Error( + 'usage: managed-workspace-write-edit-host-crash ', + ); +} + +// Resolve the root before candidate startup so this fixture proves it is using +// the same durable owner identity that the parent prepared. +const capability = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); +if (capability.rootId !== expectedRootId) { + throw new Error('Managed mutation crash fixture resolved a different storage root'); +} +const owner = await tryAcquireInteractiveRootOwner(capability); +if (!owner) throw new Error('Managed mutation crash fixture cannot own the root'); +const hostEpoch = 'managed-write-crash-host'; +const composition = await createExecutionRuntimeHostComposition( + { + owner, + hostEpoch, + acquireResidency: () => ({ release() {} }), + retainUntilProcessExit: () => undefined, + requestDrain: () => undefined, + }, + { + managedWorkspaceGitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitDigest as `sha256:${string}`, + }, + }, + { + managedWorkspaceOwnerFailpoint(point) { + if (point === 'after_managed_successor_commit') process.exit(73); + }, + }, +); + +const context = { + hostEpoch, + connectionId: 'managed-write-crash-client', + surface: 'tui' as const, + principal: 'local_os_user' as const, + acquireResidency: () => ({ release() {} }), +}; + +await composition.recover(); +process.send?.({ type: 'ready' }); +const started = await composition.handlers['turn.start']( + { + sessionId, + turnId, + content: { text: 'Write the requested managed workspace file.' }, + }, + context, +); +if (!started.ok || started.result.kind !== 'started') { + throw new Error(`Managed mutation crash fixture could not start: ${JSON.stringify(started)}`); +} + +// The owner failpoint exits after the SQLite successor/T2 transaction and +// before the Git projection is accepted. Reaching this wait means the intended +// production boundary was not crossed. +await new Promise(() => undefined); diff --git a/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts b/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts index e2d255e299..3fffc0503b 100644 --- a/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts @@ -49,6 +49,7 @@ test('binds read and mutation operations to one owner-issued managed handle', as calls.push(`admit:${input.toolName}`); return { durableDispatch: {} as never, + executionArgs: input.persistedArgs as Readonly>, async execute() { throw new Error('not used'); }, diff --git a/packages/runtime-host/src/__tests__/managed-workspace-write-edit-crash.test.ts b/packages/runtime-host/src/__tests__/managed-workspace-write-edit-crash.test.ts new file mode 100644 index 0000000000..4a46d30660 --- /dev/null +++ b/packages/runtime-host/src/__tests__/managed-workspace-write-edit-crash.test.ts @@ -0,0 +1,522 @@ +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { execFile } from 'node:child_process'; +import { test } from 'node:test'; +import { isBuiltinFilesystemWorkerSandboxAvailable } from '@maka/runtime/sandbox'; +import { createSqliteRuntimeStore } from '@maka/storage'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; +import { createExecutionRuntimeHostComposition } from '../server/execution-composition.js'; + +const execFileAsync = promisify(execFile); +const MODEL_ID = 'managed-write-crash-model'; +const CONNECTION_SLUG = 'managed-write-crash-provider'; +const API_KEY = 'managed-write-crash-key'; + +test('production Host real process crash reopens a managed Write without rerunning it', { + timeout: 120_000, + skip: isBuiltinFilesystemWorkerSandboxAvailable() + ? false + : 'The current test process has no packaged filesystem sandbox worker', +}, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-managed-write-host-crash-')); + const root = join(base, 'interactive'); + const source = join(base, 'source'); + const provider = await startManagedWriteProvider(); + let composition: Awaited> | undefined; + let restartedOwner: Awaited> | undefined; + try { + await createSourceRepository(source); + const canonicalSource = await realpath(source); + const gitExecutablePath = await findGitExecutable(); + const expectedSha256 = await sha256File(gitExecutablePath); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const setupOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(setupOwner); + if (!setupOwner) return; + let sessionId: string; + try { + const policy = await openInteractiveRuntimePolicyStoresForWrite(setupOwner.lease); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: CONNECTION_SLUG, + name: 'Managed Write crash provider', + providerType: 'moonshot', + baseUrl: provider.baseUrl, + enabled: true, + enabledModelIds: [MODEL_ID], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + const connection = created.snapshot.connections[0]; + assert.ok(connection); + if (!connection) return; + assert.equal( + ( + await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: API_KEY, + }) + ).kind, + 'committed', + ); + await publishModel(policy, connection.connectionId); + const execution = await openInteractiveExecutionStoresForWrite(setupOwner.lease); + const session = await execution.sessionStore.create({ + cwd: canonicalSource, + backend: 'ai-sdk', + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL_ID, + permissionMode: 'bypass', + toolProfile: 'managed-coding-v1', + }); + sessionId = session.id; + await execution.sessionStore.close?.(); + } finally { + await setupOwner.close(); + } + + const turnId = 'managed-write-crash-turn'; + const child = fork( + new URL('./fixtures/managed-workspace-write-edit-host-crash.js', import.meta.url), + [root, capability.rootId, gitExecutablePath, expectedSha256, sessionId, turnId], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + await waitForChildMessage(child, 'ready'); + const crash = await waitForExit(child); + assert.deepEqual(crash, { code: 73, signal: null }); + + const afterCrashOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(afterCrashOwner); + if (!afterCrashOwner) return; + let acceptedCommitOid: string; + try { + const execution = await openInteractiveExecutionStoresForWrite(afterCrashOwner.lease); + const identity = managedMutationIdentity(canonicalSource, sessionId); + const head = await readWorkspaceHead(root, identity); + assert.equal(head?.revision, 2); + assert.ok(head?.commitOid); + if (!head) return; + acceptedCommitOid = head.commitOid; + assert.equal(await countToolOutcomes(execution, sessionId, 'Write', false), 1); + await execution.sessionStore.close?.(); + } finally { + await afterCrashOwner.close(); + } + + restartedOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(restartedOwner); + if (!restartedOwner) return; + const context: ConnectionContext = { + hostEpoch: 'managed-write-restart-host', + connectionId: 'managed-write-restart-client', + surface: 'tui', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }; + composition = await createExecutionRuntimeHostComposition( + { + owner: restartedOwner, + hostEpoch: context.hostEpoch, + acquireResidency: context.acquireResidency, + retainUntilProcessExit: () => undefined, + requestDrain: () => undefined, + }, + { + managedWorkspaceGitRuntime: { executablePath: gitExecutablePath, expectedSha256 }, + }, + ); + await composition.recover(); + const probeTurn = 'managed-write-reopen-probe'; + const started = await composition.handlers['turn.start']( + { + sessionId, + turnId: probeTurn, + content: { text: 'Confirm the managed workspace is available.' }, + }, + context, + ); + assert.equal(started.ok, true); + if (!started.ok || started.result.kind !== 'started') return; + await waitForTerminal(composition, sessionId, probeTurn, context); + + const execution = await openInteractiveExecutionStoresForWrite(restartedOwner.lease); + assert.equal(await countToolOutcomes(execution, sessionId, 'Write', false), 1); + assert.equal(await countToolOutcomes(execution, sessionId, 'Edit', true), 1); + const identity = managedMutationIdentity(canonicalSource, sessionId); + const head = await readWorkspaceHead(root, identity); + assert.equal(head?.commitOid, acceptedCommitOid); + await execution.sessionStore.close?.(); + + const binding = await readOnlyBinding(root); + assert.equal(await readFile(join(binding.worktreePath, 'result.txt'), 'utf8'), 'managed\n'); + assert.equal(provider.streamRequests, 3); + assert.equal(provider.writeToolCalls, 1); + assert.equal(provider.editToolCalls, 1); + } finally { + await composition?.close(); + await restartedOwner?.close(); + await provider.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +async function countToolOutcomes( + stores: Awaited>, + sessionId: string, + toolName: 'Write' | 'Edit', + isError: boolean, +): Promise { + const runs = await stores.agentRunStore.listSessionRuns(sessionId); + const ledgers = await Promise.all( + runs.map((run) => stores.runtimeEventStore.readImmutableRuntimeEvents(sessionId, run.runId)), + ); + return ledgers + .flat() + .filter( + (event) => + event.content?.kind === 'function_response' && + event.content.name === toolName && + (event.content.isError === true) === isError, + ).length; +} + +async function waitForTerminal( + composition: Awaited>, + sessionId: string, + turnId: string, + context: ConnectionContext, +): Promise { + for (let attempt = 0; attempt < 400; attempt += 1) { + const queried = await composition.handlers['turn.query']({ sessionId, turnId }, context); + assert.equal(queried.ok, true); + if (queried.ok && ['completed', 'failed', 'cancelled'].includes(queried.result.status)) { + assert.equal(queried.result.status, 'completed'); + return; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error('Managed workspace reopen probe did not settle'); +} + +async function startManagedWriteProvider(): Promise<{ + readonly baseUrl: string; + readonly streamRequests: number; + readonly writeToolCalls: number; + readonly editToolCalls: number; + close(): Promise; +}> { + let streamRequests = 0; + let writeToolCalls = 0; + let editToolCalls = 0; + const server = createServer((request, response) => { + void (async () => { + const body = JSON.parse(await readBody(request)) as { stream?: unknown }; + if (body.stream !== true) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'managed-write-nonstream', + object: 'chat.completion', + created: 1, + model: MODEL_ID, + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'managed' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ); + return; + } + streamRequests += 1; + if (streamRequests === 1) { + writeToolCalls += 1; + respondToolCall(response, 'Write', { path: 'result.txt', content: 'managed\n' }); + } else if (streamRequests === 2) { + editToolCalls += 1; + respondToolCall(response, 'Edit', { + path: 'result.txt', + old_string: 'not-present', + new_string: 'must-not-appear', + }); + } else { + respondText(response, 'Managed workspace reopened without replaying Write.'); + } + })().catch((error) => response.destroy(error as Error)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + get streamRequests() { + return streamRequests; + }, + get writeToolCalls() { + return writeToolCalls; + }, + get editToolCalls() { + return editToolCalls; + }, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +function respondToolCall( + response: ServerResponse, + toolName: string, + args: Record, +): void { + respondChunks(response, [ + { + id: 'managed-write-tool-call', + object: 'chat.completion.chunk', + created: 1, + model: MODEL_ID, + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: 'managed-write-call-1', + type: 'function', + function: { name: toolName, arguments: JSON.stringify(args) }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'managed-write-tool-call', + object: 'chat.completion.chunk', + created: 1, + model: MODEL_ID, + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]); +} + +function respondText(response: ServerResponse, text: string): void { + respondChunks(response, [ + { + id: 'managed-write-text', + object: 'chat.completion.chunk', + created: 2, + model: MODEL_ID, + choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }], + }, + { + id: 'managed-write-text', + object: 'chat.completion.chunk', + created: 2, + model: MODEL_ID, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]); +} + +function respondChunks(response: ServerResponse, chunks: readonly unknown[]): void { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const chunk of chunks) response.write(`data: ${JSON.stringify(chunk)}\n\n`); + response.end('data: [DONE]\n\n'); +} + +async function publishModel( + policy: Awaited>, + connectionId: string, +): Promise { + const prepared = await policy.operations.beginModelFetch(connectionId); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') return; + const committed = await policy.operations.completeModelFetch(prepared.ticket, { + models: [ + { + id: MODEL_ID, + capabilities: { chat: true, functionCalling: true }, + contextWindow: 32_768, + maxOutputTokens: 64, + }, + ], + source: 'fetched', + fetchedAt: Date.now(), + }); + assert.equal(committed.kind, 'committed'); +} + +async function createSourceRepository(source: string): Promise { + await mkdir(source, { recursive: true }); + await writeFile(join(source, 'tracked.txt'), 'baseline\n', 'utf8'); + await writeFile(join(source, '.gitignore'), '.maka-workspace.json\n', 'utf8'); + await git(source, 'init', '--quiet'); + await git(source, 'add', 'tracked.txt', '.gitignore'); + await git( + source, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'baseline', + ); +} + +async function findGitExecutable(): Promise { + const command = process.platform === 'win32' ? 'where.exe' : 'which'; + const { stdout } = await execFileAsync(command, ['git'], { encoding: 'utf8' }); + const first = stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .find(Boolean); + if (!first) throw new Error('Git executable is unavailable'); + return realpath(first); +} + +async function sha256File(path: string): Promise<`sha256:${string}`> { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return `sha256:${hash.digest('hex')}`; +} + +async function git(cwd: string, ...args: string[]): Promise { + const executable = await findGitExecutable(); + const { stdout } = await execFileAsync(executable, ['-C', cwd, ...args], { + encoding: 'utf8', + }); + return stdout.trim(); +} + +function managedMutationIdentity(sourceRoot: string, sessionId: string) { + return { + workspaceId: `workspace_${domainDigest('session', sourceRoot, sessionId)}`, + workspaceEpochId: `epoch_${domainDigest('epoch', sourceRoot, sessionId)}`, + }; +} + +async function readWorkspaceHead( + root: string, + identity: ReturnType, +) { + const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite'), { readOnly: true }); + try { + return await store.readWorkspaceHead(identity.workspaceId, identity.workspaceEpochId); + } finally { + store.close(); + } +} + +function domainDigest(domain: string, ...values: readonly string[]): string { + const hash = createHash('sha256'); + hash.update(`maka-managed-mutation-${domain}-v1\0`, 'utf8'); + for (const value of values) { + hash.update(value, 'utf8'); + hash.update('\0', 'utf8'); + } + return hash.digest('hex').slice(0, 32); +} + +async function readOnlyBinding(root: string): Promise<{ worktreePath: string }> { + const files = await findNamedFiles(join(root, 'managed-workspaces'), 'binding.json'); + assert.equal(files.length, 1); + const value = JSON.parse(await readFile(files[0]!, 'utf8')) as { worktreePath?: unknown }; + assert.equal(typeof value.worktreePath, 'string'); + return { worktreePath: value.worktreePath as string }; +} + +async function findNamedFiles(root: string, name: string): Promise { + const found: string[] = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) found.push(...(await findNamedFiles(path, name))); + else if (entry.isFile() && entry.name === name) found.push(path); + } + return found; +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => resolve(body)); + request.on('error', reject); + }); +} + +function waitForChildMessage(child: ReturnType, type: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Child did not publish ${type}`)), 30_000); + child.on('message', (message: unknown) => { + if ((message as { type?: unknown })?.type !== type) return; + clearTimeout(timeout); + resolve(); + }); + child.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + reject(new Error(`Child exited before ${type}: ${String(code ?? signal)}`)); + }); + }); +} + +function waitForExit(child: ReturnType): Promise<{ + code: number | null; + signal: NodeJS.Signals | null; +}> { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('Managed mutation crash child did not exit')); + }, 60_000); + child.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + resolve({ code, signal }); + }); + }); +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 04d63a0586..77800abe93 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -76,6 +76,7 @@ import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; import { openManagedWorkspaceOwner, + type ManagedWorkspaceOwnerFailpoint, type ManagedWorkspaceFilesystemWorker, type ManagedWorkspaceOwner, type VerifiedGitRuntimeInput, @@ -195,6 +196,10 @@ export interface CreateExecutionRuntimeHostCompositionOptions { export interface ExecutionRuntimeHostCompositionDependencies { readonly primaryBackendFactory?: BackendFactory; + /** Test-only crash boundary used by production-shaped Host recovery probes. */ + readonly managedWorkspaceOwnerFailpoint?: ( + point: ManagedWorkspaceOwnerFailpoint, + ) => void | Promise; readonly oauthAuthorization?: Pick< HostOAuthCoordinatorInput, 'startCodexAuthorization' | 'pollCodexAuthorization' | 'exchangeCodexCode' @@ -345,6 +350,9 @@ export async function createExecutionRuntimeHostComposition( rootOwner: context.owner, gitRuntime: options.managedWorkspaceGitRuntime, filesystemWorker: managedFilesystemWorker, + ...(dependencies.managedWorkspaceOwnerFailpoint + ? { failpoint: dependencies.managedWorkspaceOwnerFailpoint } + : {}), }); } workspaceExecution = createRuntimeHostWorkspaceExecutionComposition({ diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index aded6db08c..77aa39be38 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1689,8 +1689,7 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { managedMutationTerminal: { action: { protocol: 'managed_mutation_terminal_v1', - disposition: 'safely_discarded', - reason: 'operation_failed_no_effect', + disposition: 'operation_failed_no_effect_committed', operationId: 'coverage-op', dispatchEventId: 'coverage-dispatch', outcomeEventId: 'coverage-outcome', diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index f3e1615032..52abf993f1 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -509,7 +509,7 @@ describe('ToolRuntime durable boundary', () => { assert.equal(JSON.stringify(result), '{"__proto__":{"safe":true}}'); }); - it('adopts an owner-committed safe discard without invoking generic T2', async () => { + it('adopts an owner-committed no-effect failure without invoking generic T2', async () => { let genericOutcomeCalls = 0; let operationId = ''; const harness = makeHarness( @@ -528,7 +528,7 @@ describe('ToolRuntime durable boundary', () => { return managedAdmission(async (operation) => { const proof = await operation(); return { - kind: 'safely_discarded', + kind: 'operation_failed_no_effect_committed', durableOutcome: proof.durableOutcome, }; }); @@ -549,7 +549,124 @@ describe('ToolRuntime durable boundary', () => { assert.equal(published?.type === 'tool_result' && published.isError, true); }); - it('snapshots a safe-discard result before its owner can mutate it', async () => { + it('publishes an owner-committed successful no-op without generic T2', async () => { + let genericOutcomeCalls = 0; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'no_workspace_change_committed', + durableOutcome: proof.durableOutcome, + }; + }), + }, + ); + const managedTool = tool(() => ({ kind: 'file_write', path: 'same.txt', bytes: 4 })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { + kind: 'file_write', + path: 'same.txt', + bytes: 4, + }); + assert.equal(genericOutcomeCalls, 0); + const published = harness.events.at(-1); + assert.equal(published?.type, 'tool_result'); + assert.equal(published?.type === 'tool_result' && published.isError, false); + }); + + it('lets the owner commit a thrown worker business error as proven no-effect', async () => { + let genericOutcomeCalls = 0; + let proofWasError = false; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + genericOutcomeCalls += 1; + return { created: true, runtimeEventSeq: 2 }; + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => + managedAdmission(async (operation) => { + const proof = await operation(); + proofWasError = proof.isError; + return { + kind: 'operation_failed_no_effect_committed', + durableOutcome: proof.durableOutcome, + }; + }), + }, + ); + const managedTool = tool(() => { + throw new Error('The requested path was not found.'); + }); + managedTool.name = 'Edit'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.deepEqual(await harness.execute(managedTool), { + error: 'The requested path was not found.', + }); + assert.equal(proofWasError, true); + assert.equal(genericOutcomeCalls, 0); + const published = harness.events.at(-1); + assert.equal(published?.type === 'tool_result' && published.isError, true); + }); + + it('executes the exact canonical arguments issued by managed admission', async () => { + let receivedArgs: unknown; + const canonicalArgs = { path: 'dir/file.txt', content: 'same' }; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => ({ + ...managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'no_workspace_change_committed', + durableOutcome: proof.durableOutcome, + }; + }), + executionArgs: canonicalArgs, + }), + }, + ); + const managedTool = tool((args) => { + receivedArgs = args; + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await harness.executeWithInput(managedTool, { path: 'dir\\file.txt', content: 'same' }); + assert.deepEqual(receivedArgs, canonicalArgs); + }); + + it('snapshots a no-effect failure result before its owner can mutate it', async () => { let operationId = ''; const ownerResult = { error: 'discarded-A' }; const appendedMessages: StoredMessage[] = []; @@ -572,7 +689,7 @@ describe('ToolRuntime durable boundary', () => { return managedAdmission(async (operation) => { const proof = await operation(); return { - kind: 'safely_discarded', + kind: 'operation_failed_no_effect_committed', durableOutcome: proof.durableOutcome, }; }); @@ -616,7 +733,7 @@ describe('ToolRuntime durable boundary', () => { retainedOperation = operation; const proof = await operation(); return { - kind: 'safely_discarded', + kind: 'operation_failed_no_effect_committed', durableOutcome: proof.durableOutcome, }; }); @@ -661,7 +778,7 @@ describe('ToolRuntime durable boundary', () => { void operation().catch(() => undefined); const result = { error: 'candidate was safely discarded' }; return { - kind: 'safely_discarded', + kind: 'operation_failed_no_effect_committed', durableOutcome: managedOutcomeEvent( operationId, { kind: 'json', value: result }, @@ -698,7 +815,7 @@ describe('ToolRuntime durable boundary', () => { ); }); - it('rejects a safe discard whose live error differs from its durable result', async () => { + it('rejects a no-effect failure whose live error differs from its durable result', async () => { let operationId = ''; const harness = makeHarness( { @@ -715,7 +832,7 @@ describe('ToolRuntime durable boundary', () => { return managedAdmission(async (operation) => { await operation(); return { - kind: 'safely_discarded', + kind: 'operation_failed_no_effect_committed', durableOutcome: managedOutcomeEvent( operationId, { kind: 'json', value: { error: 'durable replay error B' } }, @@ -738,7 +855,7 @@ describe('ToolRuntime durable boundary', () => { ); }); - it('fail-stops safe-discard canonicalization without writing generic T2', async () => { + it('fail-stops no-effect failure canonicalization without writing generic T2', async () => { let genericOutcomeCalls = 0; let operationId = ''; const providerResult = Object.defineProperty({}, 'kind', { @@ -763,7 +880,7 @@ describe('ToolRuntime durable boundary', () => { return managedAdmission(async (operation) => { const proof = await operation(); return { - kind: 'safely_discarded', + kind: 'operation_failed_no_effect_committed', durableOutcome: proof.durableOutcome, }; }); @@ -786,7 +903,7 @@ describe('ToolRuntime durable boundary', () => { ); }); - it('fail-stops an oversized safe discard before durable publication', async () => { + it('fail-stops an oversized no-effect failure before durable publication', async () => { let genericOutcomeCalls = 0; let operationId = ''; const oversized = { error: 'x'.repeat(128) }; @@ -806,7 +923,7 @@ describe('ToolRuntime durable boundary', () => { return managedAdmission(async (operation) => { const proof = await operation(); return { - kind: 'safely_discarded', + kind: 'operation_failed_no_effect_committed', durableOutcome: proof.durableOutcome, }; }); @@ -1383,6 +1500,22 @@ function makeHarness( }, }) ).result, + executeWithInput: async (target: MakaTool, input: unknown) => + ( + await runtime.settleToolCall({ + tool: target, + turnId: 'turn-1', + toolCallId: 'provider-call-1', + input, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => events.push(event), + pushAndWaitUntilConsumed: async (event) => { + events.push(event); + }, + }, + }) + ).result, executeNested: async (target: MakaTool, maxResultBytes?: number) => ( await runtime.settleToolCall({ @@ -1412,6 +1545,7 @@ function managedAdmission( ): RuntimeManagedMutationAdmission { return { durableDispatch: managedMutationDispatch(), + executionArgs: {}, execute, dispose: async () => { order?.push('dispose'); diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 100d1ec805..9fed36e1ab 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -479,13 +479,19 @@ export type RuntimeManagedMutationSettlement = readonly durableOutcome: RuntimeEvent; } | { - readonly kind: 'safely_discarded'; + readonly kind: 'no_workspace_change_committed'; + readonly durableOutcome: RuntimeEvent; + } + | { + readonly kind: 'operation_failed_no_effect_committed'; readonly durableOutcome: RuntimeEvent; } | { readonly kind: 'unsettled'; readonly error: unknown }; export interface RuntimeManagedMutationAdmission { readonly durableDispatch: Readonly; + /** Owner-issued arguments whose canonical path is identical to durableDispatch.expectedPaths. */ + readonly executionArgs: unknown; execute( operation: () => Promise, ): Promise; @@ -1316,6 +1322,7 @@ export class ToolRuntime { } let managedMutationAdmission: RuntimeManagedMutationAdmission | undefined; + let runtimeExecutionArgs = executionArgs; if (tool.durableExecutionProfile === 'managed_mutation_v1') { if ( (tool.name !== 'Write' && tool.name !== 'Edit') || @@ -1336,6 +1343,7 @@ export class ToolRuntime { persistedArgs: structuredClone(persistedArgs), abortSignal: ctx.abortSignal, }); + runtimeExecutionArgs = managedMutationAdmission.executionArgs; } catch (error) { const reason = `Managed workspace mutation admission failed: ${formatSyntheticToolErrorText(error)}`; await refuseBeforeDispatch(reason); @@ -1410,7 +1418,7 @@ export class ToolRuntime { const runId = this.input.runId; const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); const invokeTool = () => - tool.impl(structuredClone(executionArgs) as never, { + tool.impl(structuredClone(runtimeExecutionArgs) as never, { sessionId: this.input.sessionId, turnId, ...(runId ? { runId } : {}), @@ -1474,7 +1482,33 @@ export class ToolRuntime { const prepareOperationValue = async ( immutableSnapshot = false, ): Promise> => { - const rawResult = await invokeTool(); + let rawResult: unknown; + try { + rawResult = await invokeTool(); + } catch (error) { + if (!immutableSnapshot) throw error; + const message = formatSyntheticToolErrorText(error); + const result = snapshotManagedToolResult(this.errorReturn(message), ctx.maxResultBytes); + const sandboxError = serializeSandboxError(error); + const sandboxDenial = sandboxDenialSignalFromError(error); + const sandboxFailure = sandboxBoundaryFailureSignal(sandboxError); + const uncertainOutcome = uncertainOutcomeSignalFromError(error); + const content = deepFreezeRuntimeOwnedValue({ + kind: 'text', + text: message, + ...(sandboxDenial ? { sandboxDenial } : {}), + ...(sandboxFailure ? { sandboxFailure } : {}), + ...(uncertainOutcome ? { uncertainOutcome } : {}), + }); + return Object.freeze({ + result, + outcome: Object.freeze({ + content, + isError: true, + durationMs: this.input.now() - startedAt, + }), + }); + } const result = immutableSnapshot ? snapshotManagedToolResult(rawResult, ctx.maxResultBytes) : rawResult; @@ -3109,7 +3143,11 @@ function normalizeManagedMutationSettlement(settlement: unknown): durableOutcome: RuntimeEvent; } | { - kind: 'safely_discarded'; + kind: 'no_workspace_change_committed'; + durableOutcome: RuntimeEvent; + } + | { + kind: 'operation_failed_no_effect_committed'; durableOutcome: RuntimeEvent; } { if (!settlement || typeof settlement !== 'object' || Array.isArray(settlement)) { @@ -3120,7 +3158,11 @@ function normalizeManagedMutationSettlement(settlement: unknown): if (kind === 'unsettled') { throw new RuntimeManagedMutationUnsettledError(record.error); } - if (kind !== 'workspace_successor_committed' && kind !== 'safely_discarded') { + if ( + kind !== 'workspace_successor_committed' && + kind !== 'no_workspace_change_committed' && + kind !== 'operation_failed_no_effect_committed' + ) { throw new Error('Managed mutation owner returned an unknown settlement kind'); } const durableOutcomeValue = Object.hasOwn(record, 'durableOutcome') @@ -3135,16 +3177,17 @@ function normalizeManagedMutationSettlement(settlement: unknown): } const durableOutcome = durableOutcomeValue as RuntimeEvent; - if (kind === 'workspace_successor_committed') { - return { - kind, - durableOutcome, - }; - } - const response = durableOutcome.content; - if (response?.kind !== 'function_response' || response.isError !== true) { - throw new Error('Managed safely-discarded settlement has no durable error outcome'); + if (response?.kind !== 'function_response') { + throw new Error('Managed mutation settlement has no durable function response'); + } + const expectsError = kind === 'operation_failed_no_effect_committed'; + if ((response.isError === true) !== expectsError) { + throw new Error( + expectsError + ? 'Managed no-effect failure settlement has no durable error outcome' + : 'Managed successful settlement has no durable success outcome', + ); } return { kind, diff --git a/packages/storage/src/__tests__/managed-workspace-owner.test.ts b/packages/storage/src/__tests__/managed-workspace-owner.test.ts index 4ad54b22be..4275fd9b4e 100644 --- a/packages/storage/src/__tests__/managed-workspace-owner.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-owner.test.ts @@ -217,7 +217,7 @@ test('publishes only a revocable execution scope through its accepted handle', a } }); -test('freezes a managed Write admission from the owner-bound head and worker profile', async () => { +test('freezes canonical Write/Edit admission from the owner-bound head and worker profile', async () => { const root = await temporaryRoot(); const storageRoot = join(root, 'storage'); const sourceRoot = await createEligibleSource(join(root, 'source')); @@ -244,10 +244,11 @@ test('freezes a managed Write admission from the owner-bound head and worker pro openRequest(sourceRoot), ); + const inputPath = process.platform === 'win32' ? 'dir\\tracked.txt' : 'dir/tracked.txt'; const admission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { operationId: 'operation-managed-write-1', toolName: 'Write', - persistedArgs: { path: 'tracked.txt', content: 'updated\n' }, + persistedArgs: { path: inputPath, content: 'updated\n' }, abortSignal: new AbortController().signal, }); @@ -263,13 +264,34 @@ test('freezes a managed Write admission from the owner-bound head and worker pro baseHeadRevision: accepted.head.revision, baseCommitOid: accepted.head.commitOid, baseTreeOid: accepted.head.treeOid, - expectedPaths: ['tracked.txt'], + expectedPaths: ['dir/tracked.txt'], executionProfileDigest: admission.durableDispatch.executionProfileDigest, }); + assert.deepEqual(admission.executionArgs, { + path: 'dir/tracked.txt', + content: 'updated\n', + }); assert.match(admission.durableDispatch.executionProfileDigest, /^sha256:[a-f0-9]{64}$/u); assert.equal('executionProfileDigest' in admission, false); await admission.dispose(); + const editAdmission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { + operationId: 'operation-managed-edit-1', + toolName: 'Edit', + persistedArgs: { + path: inputPath, + old_string: 'before', + new_string: 'after', + }, + abortSignal: new AbortController().signal, + }); + assert.deepEqual(editAdmission.durableDispatch.expectedPaths, ['dir/tracked.txt']); + assert.deepEqual(editAdmission.executionArgs, { + path: 'dir/tracked.txt', + old_string: 'before', + new_string: 'after', + }); + await editAdmission.dispose(); await owner.close(); } finally { runtimeStore.close(); @@ -498,7 +520,7 @@ test('atomically settles failed and successful no-effect Writes without advancin durableOutcome: managedMutationOutcome(operationId, toolCallId, result, true), })); - assert.equal(settlement.kind, 'safely_discarded'); + assert.equal(settlement.kind, 'operation_failed_no_effect_committed'); assert.equal( ( await runtimeStore.readWorkspaceHead( @@ -564,7 +586,7 @@ test('atomically settles failed and successful no-effect Writes without advancin ), }; }); - assert.equal(noChangeSettlement.kind, 'safely_discarded'); + assert.equal(noChangeSettlement.kind, 'no_workspace_change_committed'); assert.equal( ( await runtimeStore.readWorkspaceHead( diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index 0ea319c7e5..3c99bffb95 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -783,8 +783,7 @@ describe('workspace version persistence authority', () => { actions: { managedMutationTerminal: { protocol: 'managed_mutation_terminal_v1', - disposition: 'safely_discarded', - reason: 'no_workspace_change', + disposition: 'no_workspace_change_committed', operationId: prepared.operationId, dispatchEventId: prepared.dispatchRuntimeEvent.id, outcomeEventId: 'workspace-terminal-bypass-outcome', diff --git a/packages/storage/src/managed-workspace-owner.ts b/packages/storage/src/managed-workspace-owner.ts index f67f8168cb..e190375047 100644 --- a/packages/storage/src/managed-workspace-owner.ts +++ b/packages/storage/src/managed-workspace-owner.ts @@ -123,6 +123,7 @@ export interface AdmitManagedWorkspaceMutationInput { export interface ManagedWorkspaceMutationAdmission { readonly durableDispatch: Readonly; + readonly executionArgs: Readonly>; execute( operation: () => Promise, ): Promise; @@ -143,7 +144,11 @@ export type ManagedWorkspaceMutationSettlement = readonly durableOutcome: import('@maka/core/runtime-event').RuntimeEvent; } | { - readonly kind: 'safely_discarded'; + readonly kind: 'no_workspace_change_committed'; + readonly durableOutcome: import('@maka/core/runtime-event').RuntimeEvent; + } + | { + readonly kind: 'operation_failed_no_effect_committed'; readonly durableOutcome: import('@maka/core/runtime-event').RuntimeEvent; }; @@ -548,7 +553,8 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { `Managed workspace already has active mutation ${active.operationId}`, ); } - const expectedPath = canonicalManagedMutationPath(input.toolName, input.persistedArgs); + const canonicalInput = canonicalManagedMutationInput(input.toolName, input.persistedArgs); + const expectedPath = canonicalInput.path; input.abortSignal.throwIfAborted(); const durableDispatch = Object.freeze({ protocol: 'managed_mutation_v1' as const, @@ -600,7 +606,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { if (proof.isError) { await this.#requireReady(accepted.binding); await commitManagedMutationTerminalInternal(accepted.store, { - reason: 'operation_failed_no_effect', + disposition: 'operation_failed_no_effect_committed', toolOutcome: { operationId: input.operationId, journalEventId: `${input.operationId}_outcome`, @@ -609,7 +615,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { }, }); return Object.freeze({ - kind: 'safely_discarded' as const, + kind: 'operation_failed_no_effect_committed' as const, durableOutcome: proof.durableOutcome, }); } @@ -631,7 +637,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { } await this.#requireReady(accepted.binding); await commitManagedMutationTerminalInternal(accepted.store, { - reason: 'no_workspace_change', + disposition: 'no_workspace_change_committed', toolOutcome: { operationId: input.operationId, journalEventId: `${input.operationId}_outcome`, @@ -640,7 +646,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { }, }); return Object.freeze({ - kind: 'safely_discarded' as const, + kind: 'no_workspace_change_committed' as const, durableOutcome: proof.durableOutcome, }); } @@ -703,6 +709,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { }; return Object.freeze({ durableDispatch, + executionArgs: canonicalInput.executionArgs, execute, async dispose() { if (state === 'open') state = 'closed'; @@ -879,7 +886,13 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { } } -function canonicalManagedMutationPath(toolName: 'Write' | 'Edit', persistedArgs: unknown): string { +function canonicalManagedMutationInput( + toolName: 'Write' | 'Edit', + persistedArgs: unknown, +): { + readonly path: string; + readonly executionArgs: Readonly>; +} { if (!persistedArgs || typeof persistedArgs !== 'object' || Array.isArray(persistedArgs)) { throw new ManagedWorkspaceOwnerError( 'managed_workspace_owner_unavailable', @@ -908,7 +921,15 @@ function canonicalManagedMutationPath(toolName: 'Write' | 'Edit', persistedArgs: 'Managed workspace mutation path is not a canonical tracked file path', ); } - return path; + const executionArgs = + toolName === 'Write' + ? Object.freeze({ path, content: record.content as string }) + : Object.freeze({ + path, + old_string: record.old_string as string, + new_string: record.new_string as string, + }); + return Object.freeze({ path, executionArgs }); } function assertManagedMutationReservation( diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index b117c9dd33..440ba77e1a 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -1484,7 +1484,7 @@ export class SqliteRuntimeStore dispatchEvent, mutation, outcomeEvent: toolOutcome.runtimeEvent, - reason: input.reason, + disposition: input.disposition, }); const existingTerminalJson = this.readRuntimeEventJson(terminalEvent.id); if (operation.resultEventId !== undefined || existingTerminalJson !== undefined) { @@ -1557,9 +1557,12 @@ export class SqliteRuntimeStore const response = toolOutcome.runtimeEvent.content; if ( response?.kind !== 'function_response' || - (input.reason === 'operation_failed_no_effect') !== (response.isError === true) + (input.disposition === 'operation_failed_no_effect_committed') !== + (response.isError === true) ) { - throw new Error('Managed mutation terminal reason conflicts with its exact tool outcome'); + throw new Error( + 'Managed mutation terminal disposition conflicts with its exact tool outcome', + ); } const outcomeResult = this.commitToolOutcomeSync(toolOutcome, 'workspace_terminal'); @@ -4261,14 +4264,13 @@ function buildManagedMutationTerminalEvent(input: { readonly dispatchEvent: RuntimeEvent; readonly mutation: RuntimeEventManagedWorkspaceMutationV1; readonly outcomeEvent: RuntimeEvent; - readonly reason: RuntimeEventManagedWorkspaceMutationTerminalV1['reason']; + readonly disposition: RuntimeEventManagedWorkspaceMutationTerminalV1['disposition']; }): RuntimeEvent { const dispatch = input.dispatchEvent.actions?.toolDispatch; if (!dispatch) throw new Error('Managed mutation terminal requires a dispatch event'); const terminal: RuntimeEventManagedWorkspaceMutationTerminalV1 = { protocol: 'managed_mutation_terminal_v1', - disposition: 'safely_discarded', - reason: input.reason, + disposition: input.disposition, operationId: dispatch.operationId, dispatchEventId: input.dispatchEvent.id, outcomeEventId: input.outcomeEvent.id, @@ -4317,7 +4319,7 @@ function scanManagedMutationTerminalFacts( dispatchEvent, mutation: dispatch.managedMutation, outcomeEvent: response, - reason: terminal.reason, + disposition: terminal.disposition, }) : undefined; const actionKeys = event.actions ? Object.keys(event.actions) : []; @@ -4347,7 +4349,7 @@ function scanManagedMutationTerminalFacts( event.runId !== dispatchEvent.runId || event.turnId !== dispatchEvent.turnId || (eventOrder.get(event.id) ?? -1) <= (eventOrder.get(response.id) ?? -1) || - (terminal.reason === 'operation_failed_no_effect') !== + (terminal.disposition === 'operation_failed_no_effect_committed') !== (response.content?.kind === 'function_response' && response.content.isError === true) || terminalOperations.has(terminal.operationId) ) { diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index eea1b74c3b..fa26fbd324 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -30,7 +30,7 @@ export interface WorkspaceSuccessorCommitResult { outcomeRuntimeEventSeq: number; } export interface ManagedMutationTerminalCommitInput { - readonly reason: 'operation_failed_no_effect' | 'no_workspace_change'; + readonly disposition: 'operation_failed_no_effect_committed' | 'no_workspace_change_committed'; readonly toolOutcome: WorkspaceSuccessorCommitInput['toolOutcome']; } export interface ManagedMutationTerminalCommitResult { diff --git a/scripts/recovery-test-inventory.mjs b/scripts/recovery-test-inventory.mjs index 1f2c890c26..353704d045 100644 --- a/scripts/recovery-test-inventory.mjs +++ b/scripts/recovery-test-inventory.mjs @@ -18,6 +18,9 @@ export const RECOVERY_TEST_INVENTORIES = Object.freeze({ 'packages/storage/src/sqlite-runtime-schema.ts', 'packages/storage/src/sqlite-runtime-store.ts', 'packages/storage/src/workspace-version-authority-internal.ts', + 'packages/runtime/src/tool-runtime.ts', + 'packages/runtime-host/src/server/execution-composition.ts', + 'packages/runtime-host/src/server/managed-workspace-mutation-session.ts', 'packages/storage/src/__tests__/managed-workspace-baseline.test.ts', 'packages/storage/src/__tests__/git-workspace-service.test.ts', 'packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts', @@ -26,6 +29,8 @@ export const RECOVERY_TEST_INVENTORIES = Object.freeze({ 'packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts', 'packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts', 'packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts', + 'packages/runtime-host/src/__tests__/managed-workspace-write-edit-crash.test.ts', + 'packages/runtime-host/src/__tests__/fixtures/managed-workspace-write-edit-host-crash.ts', ]), testNamePattern: 'real process crash|real process is killed|real crash|real-process crash|crash after baseline ref publication|durable managed mutation ownership', @@ -36,11 +41,15 @@ export const RECOVERY_TEST_INVENTORIES = Object.freeze({ 'packages/storage/dist/__tests__/managed-workspace-owner.test.js', 'packages/storage/dist/__tests__/sqlite-runtime-crash.test.js', 'packages/storage/dist/__tests__/sqlite-recovery-concurrency.test.js', + 'packages/runtime-host/dist/__tests__/managed-workspace-write-edit-crash.test.js', ]), expectedByPlatform: Object.freeze({ - darwin: Object.freeze({ tests: 29, pass: 29, skipped: 0 }), - linux: Object.freeze({ tests: 29, pass: 29, skipped: 0 }), - win32: Object.freeze({ tests: 29, pass: 29, skipped: 0 }), + darwin: Object.freeze({ tests: 30, pass: 30, skipped: 0 }), + linux: Object.freeze({ tests: 30, pass: 30, skipped: 0 }), + // The Windows release contains the sandbox broker, but the recovery + // runner does not currently build/package that Rust artifact. Keep the + // full Host test visible as an explicit platform evidence gap. + win32: Object.freeze({ tests: 30, pass: 29, skipped: 1 }), }), }), }); From ff416e6d3aa8d269d63f5ca11458dba226e41589 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Tue, 18 Aug 2026 21:56:26 +0800 Subject: [PATCH 4/9] fix(runtime): close managed mutation preflight boundary --- ...orkspace-write-edit-production-v1.zh-CN.md | 10 +- ...managed-workspace-mutation-session.test.ts | 2 +- .../tool-runtime-durable-boundary.test.ts | 108 +++++++++++++++++- packages/runtime/src/tool-runtime.ts | 43 ++++++- .../__tests__/managed-workspace-owner.test.ts | 34 +++--- .../storage/src/managed-workspace-owner.ts | 28 +---- 6 files changed, 177 insertions(+), 48 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md index 58ca8b51a2..4c16fef407 100644 --- a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md @@ -22,7 +22,10 @@ explicit managed-coding-v1 profile caller 不能提供 cwd、base head、candidate、execution profile digest 或 terminal result。普通 `headless-coding-v1` 不会因为 bundled Git/worker 恰好可用而静默升级;只有显式 profile 在 T1 前选择 -`managed_mutation_v1`。缺失 owner、worker、Git、SQLite authority 或任一 identity mismatch 都在 T1 前拒绝。 +`managed_mutation_v1`。execution boundary 读取、owner admission、canonical path 和其他可失败 preflight +必须在 T1 前完成;缺失 owner、worker、Git、SQLite authority 或任一 identity mismatch 都在 T1 前拒绝。 +Owner 只签发与 `expectedPaths[0]` 相同的 `canonicalPath`,没有改写 `content`、`old_string` 或 +`new_string` 的权限;真实执行参数由 Runtime 从已经校验并写入 durable call 的原始参数重建。 首版 profile 的工具面严格限定为 `Read/Glob/Grep/Write/Edit`。`Bash` 与 `apply_patch` 尚无等价的 effect owner 和 successor 协议,因此不会暴露给 managed task;不能借由这些工具绕过 canonical workspace head。 @@ -70,11 +73,16 @@ terminal fact、T1 mutation identity、dispatch/outcome identity、base head 与 中都要一致。generic append/import 和 generic T2 writer 无权写入该 fact。若 worker 可能已改变文件、Git 状态漂移、 terminal fact 缺失/损坏或 candidate 无法重验,则保留 reservation 并 fail-stop/park。 +T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用唯一一次 operation capability;若 worker 因已经 +取消而在产生副作用前拒绝,Runtime 将该拒绝冻结为 error proof,Owner 在 clean-base 证明后提交 +`operation_failed_no_effect_committed`。如果 clean-base 无法证明,则保持 `unsettled`。 + ## 4. 失败状态与回滚 | 崩溃/失败点 | durable 状态 | 重启行为 | |---|---|---| | T1 前 | 无 reservation | 明确失败,可重新 admission | +| T1 后取消,worker 明确未产生 effect 且 Git clean | error T2 + terminal fact | 已收敛,head 不变 | | T1 后、worker 前/中,effect 不可证明 | reservation 保留 | park;M2.4 不自动猜测或覆盖 | | worker error 且 Git owner 证明 clean base | error T2 + terminal fact | 已收敛,head 不变 | | success 但 tree 无变化 | success T2 + terminal fact | 已收敛,head 不变 | diff --git a/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts b/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts index 3fffc0503b..eb033e1e80 100644 --- a/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts @@ -49,7 +49,7 @@ test('binds read and mutation operations to one owner-issued managed handle', as calls.push(`admit:${input.toolName}`); return { durableDispatch: {} as never, - executionArgs: input.persistedArgs as Readonly>, + canonicalPath: (input.persistedArgs as Readonly>).path ?? '', async execute() { throw new Error('not used'); }, diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 52abf993f1..b40a731ec1 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -629,7 +629,7 @@ describe('ToolRuntime durable boundary', () => { assert.equal(published?.type === 'tool_result' && published.isError, true); }); - it('executes the exact canonical arguments issued by managed admission', async () => { + it('executes the Runtime arguments with the canonical path issued by managed admission', async () => { let receivedArgs: unknown; const canonicalArgs = { path: 'dir/file.txt', content: 'same' }; const harness = makeHarness( @@ -650,7 +650,11 @@ describe('ToolRuntime durable boundary', () => { durableOutcome: proof.durableOutcome, }; }), - executionArgs: canonicalArgs, + durableDispatch: { + ...managedMutationDispatch(), + expectedPaths: ['dir/file.txt'], + }, + canonicalPath: canonicalArgs.path, }), }, ); @@ -666,6 +670,104 @@ describe('ToolRuntime durable boundary', () => { assert.deepEqual(receivedArgs, canonicalArgs); }); + it('resolves the managed execution boundary before committing T1', async () => { + let preparedCalls = 0; + let operationCalls = 0; + let disposeCalls = 0; + const harness = makeHarness( + { + commitToolPrepared: async () => { + preparedCalls += 1; + return { created: true, runtimeEventSeq: 1 }; + }, + commitToolOutcome: async () => { + throw new Error('must not reach T2'); + }, + }, + undefined, + 'run-1', + { + readExecutionBoundary: async () => { + throw new Error('execution boundary unavailable'); + }, + admitManagedMutation: async () => ({ + ...managedAdmission(async (operation) => { + operationCalls += 1; + const proof = await operation(); + return { + kind: 'workspace_successor_committed', + durableOutcome: proof.durableOutcome, + }; + }), + dispose: async () => { + disposeCalls += 1; + }, + }), + }, + ); + const managedTool = tool(() => ({ ok: true })); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + assert.match( + JSON.stringify(await harness.execute(managedTool)), + /execution boundary unavailable/u, + ); + assert.equal(preparedCalls, 0); + assert.equal(operationCalls, 0); + assert.equal(disposeCalls, 1); + }); + + it('does not let managed admission rewrite non-path tool arguments', async () => { + let receivedArgs: unknown; + const harness = makeHarness( + { + commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }), + commitToolOutcome: async () => { + throw new Error('generic T2 must not settle a managed mutation'); + }, + }, + undefined, + 'run-1', + { + admitManagedMutation: async () => ({ + ...managedAdmission(async (operation) => { + const proof = await operation(); + return { + kind: 'no_workspace_change_committed', + durableOutcome: proof.durableOutcome, + }; + }), + durableDispatch: { + ...managedMutationDispatch(), + expectedPaths: ['dir/file.txt'], + }, + canonicalPath: 'dir/file.txt', + // A malformed Host may still attach extra data at runtime. Runtime + // must ignore it because the protocol grants path authority only. + executionArgs: { path: 'dir/file.txt', content: 'owner-rewritten' }, + }), + }, + ); + const managedTool = tool((args) => { + receivedArgs = args; + return { ok: true }; + }); + managedTool.name = 'Write'; + managedTool.recoveryMode = 'reconcile'; + managedTool.durableExecutionProfile = 'managed_mutation_v1'; + + await harness.executeWithInput(managedTool, { + path: 'dir\\file.txt', + content: 'runtime-validated', + }); + assert.deepEqual(receivedArgs, { + path: 'dir/file.txt', + content: 'runtime-validated', + }); + }); + it('snapshots a no-effect failure result before its owner can mutate it', async () => { let operationId = ''; const ownerResult = { error: 'discarded-A' }; @@ -1545,7 +1647,7 @@ function managedAdmission( ): RuntimeManagedMutationAdmission { return { durableDispatch: managedMutationDispatch(), - executionArgs: {}, + canonicalPath: 'notes.txt', execute, dispose: async () => { order?.push('dispose'); diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 9fed36e1ab..2644f73fbe 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -490,8 +490,8 @@ export type RuntimeManagedMutationSettlement = export interface RuntimeManagedMutationAdmission { readonly durableDispatch: Readonly; - /** Owner-issued arguments whose canonical path is identical to durableDispatch.expectedPaths. */ - readonly executionArgs: unknown; + /** Owner-issued canonical identity for the sole path in durableDispatch.expectedPaths. */ + readonly canonicalPath: string; execute( operation: () => Promise, ): Promise; @@ -499,6 +499,28 @@ export interface RuntimeManagedMutationAdmission { dispose(): Promise; } +function managedMutationExecutionArgs( + toolName: 'Write' | 'Edit', + executionArgs: unknown, + admission: RuntimeManagedMutationAdmission, +): unknown { + const expectedPaths = admission.durableDispatch.expectedPaths; + if ( + expectedPaths.length !== 1 || + admission.canonicalPath.length === 0 || + admission.canonicalPath !== expectedPaths[0] + ) { + throw new Error('Managed mutation canonical path does not match its durable dispatch'); + } + if (!executionArgs || typeof executionArgs !== 'object' || Array.isArray(executionArgs)) { + throw new Error(`Managed ${toolName} execution arguments are invalid`); + } + const runtimeOwnedArgs = structuredClone(executionArgs) as Record; + // The owner has authority to canonicalize path identity only. Every other + // field remains the exact Runtime-validated value bound to the durable call. + return Object.freeze({ ...runtimeOwnedArgs, path: admission.canonicalPath }); +} + interface DurableToolAttempt { operationId: string; responseEventId: string; @@ -1322,6 +1344,7 @@ export class ToolRuntime { } let managedMutationAdmission: RuntimeManagedMutationAdmission | undefined; + let managedMutationExecutionBoundary: ExecutionBoundary | undefined; let runtimeExecutionArgs = executionArgs; if (tool.durableExecutionProfile === 'managed_mutation_v1') { if ( @@ -1343,8 +1366,17 @@ export class ToolRuntime { persistedArgs: structuredClone(persistedArgs), abortSignal: ctx.abortSignal, }); - runtimeExecutionArgs = managedMutationAdmission.executionArgs; + runtimeExecutionArgs = managedMutationExecutionArgs( + tool.name, + executionArgs, + managedMutationAdmission, + ); + // This is the last fallible Runtime preflight for managed execution. + // Resolve it before T1 so every post-T1 path enters the owner-scoped + // operation proof state machine. + managedMutationExecutionBoundary = await this.readExecutionBoundary(); } catch (error) { + await managedMutationAdmission?.dispose(); const reason = `Managed workspace mutation admission failed: ${formatSyntheticToolErrorText(error)}`; await refuseBeforeDispatch(reason); this.recordLoopGateOutcome(callSignature, true); @@ -1416,7 +1448,10 @@ export class ToolRuntime { pauseTarget?.pause(); try { const runId = this.input.runId; - const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); + const executionBoundary = + clientCapabilityBoundary ?? + managedMutationExecutionBoundary ?? + (await this.readExecutionBoundary()); const invokeTool = () => tool.impl(structuredClone(runtimeExecutionArgs) as never, { sessionId: this.input.sessionId, diff --git a/packages/storage/src/__tests__/managed-workspace-owner.test.ts b/packages/storage/src/__tests__/managed-workspace-owner.test.ts index 4275fd9b4e..8b75dd104a 100644 --- a/packages/storage/src/__tests__/managed-workspace-owner.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-owner.test.ts @@ -267,10 +267,7 @@ test('freezes canonical Write/Edit admission from the owner-bound head and worke expectedPaths: ['dir/tracked.txt'], executionProfileDigest: admission.durableDispatch.executionProfileDigest, }); - assert.deepEqual(admission.executionArgs, { - path: 'dir/tracked.txt', - content: 'updated\n', - }); + assert.equal(admission.canonicalPath, 'dir/tracked.txt'); assert.match(admission.durableDispatch.executionProfileDigest, /^sha256:[a-f0-9]{64}$/u); assert.equal('executionProfileDigest' in admission, false); @@ -286,11 +283,7 @@ test('freezes canonical Write/Edit admission from the owner-bound head and worke abortSignal: new AbortController().signal, }); assert.deepEqual(editAdmission.durableDispatch.expectedPaths, ['dir/tracked.txt']); - assert.deepEqual(editAdmission.executionArgs, { - path: 'dir/tracked.txt', - old_string: 'before', - new_string: 'after', - }); + assert.equal(editAdmission.canonicalPath, 'dir/tracked.txt'); await editAdmission.dispose(); await owner.close(); } finally { @@ -462,7 +455,7 @@ test('accepts a worker-owned Write only after capturing its Git candidate', asyn } }); -test('atomically settles failed and successful no-effect Writes without advancing the head', async () => { +test('atomically settles post-T1 failure and successful no-effect Writes without advancing the head', async () => { const root = await temporaryRoot(); const storageRoot = join(root, 'storage'); const sourceRoot = await createEligibleSource(join(root, 'source')); @@ -500,11 +493,12 @@ test('atomically settles failed and successful no-effect Writes without advancin runtimeStore, openRequest(sourceRoot), ); + const controller = new AbortController(); const admission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { operationId, toolName: 'Write', persistedArgs: args, - abortSignal: new AbortController().signal, + abortSignal: controller.signal, }); await commitManagedMutationT1(runtimeStore, admission.durableDispatch, { operationId, @@ -512,14 +506,20 @@ test('atomically settles failed and successful no-effect Writes without advancin args, }); const result = { kind: 'text' as const, text: 'Write failed before changing the workspace' }; + controller.abort(new Error('cancelled after T1')); + let operationProofCalls = 0; - const settlement = await admission.execute(async () => ({ - content: result, - isError: true, - durationMs: 1, - durableOutcome: managedMutationOutcome(operationId, toolCallId, result, true), - })); + const settlement = await admission.execute(async () => { + operationProofCalls += 1; + return { + content: result, + isError: true, + durationMs: 1, + durableOutcome: managedMutationOutcome(operationId, toolCallId, result, true), + }; + }); + assert.equal(operationProofCalls, 1); assert.equal(settlement.kind, 'operation_failed_no_effect_committed'); assert.equal( ( diff --git a/packages/storage/src/managed-workspace-owner.ts b/packages/storage/src/managed-workspace-owner.ts index e190375047..2bc0065cb6 100644 --- a/packages/storage/src/managed-workspace-owner.ts +++ b/packages/storage/src/managed-workspace-owner.ts @@ -123,7 +123,7 @@ export interface AdmitManagedWorkspaceMutationInput { export interface ManagedWorkspaceMutationAdmission { readonly durableDispatch: Readonly; - readonly executionArgs: Readonly>; + readonly canonicalPath: string; execute( operation: () => Promise, ): Promise; @@ -553,8 +553,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { `Managed workspace already has active mutation ${active.operationId}`, ); } - const canonicalInput = canonicalManagedMutationInput(input.toolName, input.persistedArgs); - const expectedPath = canonicalInput.path; + const canonicalPath = canonicalManagedMutationPath(input.toolName, input.persistedArgs); input.abortSignal.throwIfAborted(); const durableDispatch = Object.freeze({ protocol: 'managed_mutation_v1' as const, @@ -568,7 +567,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { baseHeadRevision: currentHead.revision, baseCommitOid: currentHead.commitOid, baseTreeOid: currentHead.treeOid, - expectedPaths: Object.freeze([expectedPath]), + expectedPaths: Object.freeze([canonicalPath]), executionProfileDigest: this.#workerBridge.mutationExecutionProfileDigest, }); let state: 'open' | 'executing' | 'closed' = 'open'; @@ -584,7 +583,6 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { state = 'executing'; try { return await this.#run(async () => { - input.abortSignal.throwIfAborted(); const reservation = await readActiveManagedMutationInternal( accepted.store, accepted.binding.workspaceInstanceId, @@ -709,7 +707,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { }; return Object.freeze({ durableDispatch, - executionArgs: canonicalInput.executionArgs, + canonicalPath, execute, async dispose() { if (state === 'open') state = 'closed'; @@ -886,13 +884,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { } } -function canonicalManagedMutationInput( - toolName: 'Write' | 'Edit', - persistedArgs: unknown, -): { - readonly path: string; - readonly executionArgs: Readonly>; -} { +function canonicalManagedMutationPath(toolName: 'Write' | 'Edit', persistedArgs: unknown): string { if (!persistedArgs || typeof persistedArgs !== 'object' || Array.isArray(persistedArgs)) { throw new ManagedWorkspaceOwnerError( 'managed_workspace_owner_unavailable', @@ -921,15 +913,7 @@ function canonicalManagedMutationInput( 'Managed workspace mutation path is not a canonical tracked file path', ); } - const executionArgs = - toolName === 'Write' - ? Object.freeze({ path, content: record.content as string }) - : Object.freeze({ - path, - old_string: record.old_string as string, - new_string: record.new_string as string, - }); - return Object.freeze({ path, executionArgs }); + return path; } function assertManagedMutationReservation( From b0820f61d0ab33ae9ca33aa05f3c0a3c1024269b Mon Sep 17 00:00:00 2001 From: zhiiw Date: Tue, 18 Aug 2026 22:36:23 +0800 Subject: [PATCH 5/9] fix(storage): bind managed mutations to exact blobs --- ...e-git-mutation-candidate-owner-v1.zh-CN.md | 9 +- ...orkspace-write-edit-production-v1.zh-CN.md | 14 +- .../core/src/__tests__/runtime-event.test.ts | 17 +++ packages/core/src/runtime-event.ts | 12 +- ...managed-workspace-mutation-session.test.ts | 1 + .../src/server/execution-composition.ts | 11 +- .../src/__tests__/filesystem-worker.test.ts | 55 +++++++ .../runtime-event-read-model.test.ts | 1 + .../tool-runtime-durable-boundary.test.ts | 1 + .../runtime/src/filesystem-worker/client.ts | 5 + .../src/filesystem-worker/operations.ts | 46 +++++- .../runtime/src/filesystem-worker/protocol.ts | 38 ++++- .../git-workspace-service-crash-child.ts | 11 ++ .../sqlite-recovery-concurrency-child.ts | 1 + ...naged-mutation-candidate-authority.test.ts | 18 ++- .../__tests__/managed-workspace-owner.test.ts | 135 ++++++++++++++++-- .../__tests__/sqlite-runtime-crash.test.ts | 1 + ...pace-version-authority-persistence.test.ts | 2 + packages/storage/src/git-workspace-service.ts | 114 ++++++++++++++- ...d-mutation-candidate-authority-internal.ts | 8 ++ ...-workspace-execution-authority-internal.ts | 2 + .../storage/src/managed-workspace-owner.ts | 52 ++++++- ...anaged-workspace-worker-bridge-internal.ts | 23 ++- packages/storage/src/sqlite-runtime-store.ts | 11 ++ .../workspace-version-authority-internal.ts | 1 + scripts/recovery-test-inventory.mjs | 6 + 26 files changed, 562 insertions(+), 33 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md index eee36724e5..43caba2035 100644 --- a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md @@ -14,6 +14,7 @@ ```text accepted base commit + declared changed paths + + exact resulting blob for the sole path + fixed candidate policy + execution profile digest ↓ @@ -39,6 +40,7 @@ config 和 storage-root writer lock。M2.2 通过 storage-internal `WeakMap` cap - worktree 是非 symlink 目录,common-dir 指向 Maka repository,worktree lock 仍存在; - worktree `HEAD`、managed head ref、base commit/tree 同时匹配; - status 只有声明的路径,没有 ignored、rename/copy 或额外变化; +- 首版单文件 mutation 的 candidate tree 中,目标 blob 必须精确等于 worker result blob(删除为 absent); - candidate 全树只有普通 blob mode `100644/100755`,没有 symlink、submodule、special mode、属性文件或大小写冲突; - candidate commit 只有一个 parent,且 commit identity/message 使用固定协议; - receipt 的 ref、commit、tree、parent、递归文件级 delta digest 和路径集合可从 Git object database 重算; @@ -55,7 +57,7 @@ sequenceDiagram participant R as Maka Git Repository participant D as Durable Receipt Directory - C->>G: capture(binding, operation, baseHead, expectedPaths, profile) + C->>G: capture(binding, operation, baseHead, expectedPath, expectedBlob, profile) G->>G: acquire storage-root writer lock G->>G: verify binding, worktree owner, exact base and status G->>R: read-tree(base) into private temporary index @@ -95,6 +97,7 @@ tombstone 在删除 ref 前落盘,所以崩溃后不会把“外部删 ref” |---|---|---| | base/head/worktree owner 漂移 | `managed_workspace_drifted` 或 identity conflict | park;不发布 ref | | undeclared、ignored、dependency/control path | `managed_mutation_candidate_rejected` | 不发布 ref | +| candidate 目标 blob 与 worker result blob 不同 | `managed_mutation_candidate_rejected` | 不发布 ref;park/quarantine | | symlink/submodule/special mode/attributes/case collision | reject | 不发布 receipt;已生成 object 可 GC | | 同 operation 已有不同 ref/receipt | identity conflict | fail closed | | ref 后崩溃 | ref 保留、receipt 缺失 | exact capture 重放 | @@ -123,9 +126,9 @@ receipt 证据,不能退化成顶层目录名。 ## 7. 留给 M2.3a/M2.3b/M2.4 的边界 -M2.2 不证明变化一定由某一次 Write/Edit 造成。M2.3a 必须把 T1 identity 与 durable exclusive reservation +M2.2 单独不证明调用者提供的 expected blob 一定由某一次 Write/Edit 造成。M2.3a 必须把 T1 identity 与 durable exclusive reservation 原子绑定并把 exact changed paths 带入 accepted truth;M2.3b 在 T1 前冻结 owner-bound execution admission; -M2.4 必须核对正常工具 transform 的 expected result,调用 M2.1 原子 bundle,并在真实 Host kill/reopen +M2.4 必须让真实 worker 验证 T1 base blob、返回 result blob,再由 candidate owner 核对 immutable tree,调用 M2.1 原子 bundle,并在真实 Host kill/reopen 测试中证明唯一 accepted successor。 因此本切片保持 Draft;它没有生产 consumer,也不提升当前 Desktop 的 resume 能力。 diff --git a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md index 4c16fef407..586085b3cb 100644 --- a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md @@ -13,9 +13,10 @@ explicit managed-coding-v1 profile -> Host 打开同一个 owner-bound managed workspace execution handle -> owner 从真实 filesystem worker permission profile + protocol 计算 profile digest - -> T1 原子持久化 call + dispatch + exact base/path/profile + exclusive reservation - -> worker 只在该 admission 的 mutation scope 内执行 - -> Git owner capture exact candidate + -> Git owner 从 accepted base tree 读取目标路径的 exact base blob + -> T1 原子持久化 call + dispatch + exact base tree/blob/path/profile + exclusive reservation + -> worker 在同一 transform 进程内先验证 base blob,再返回 exact result blob + -> Git owner capture candidate,并验证 immutable candidate tree 的目标 blob 等于 worker result blob -> SQLite 原子提交 exact T2 + successor fact + canonical head,并释放 reservation -> Git candidate 幂等 accept,将 worktree 投影到 SQLite accepted head ``` @@ -44,6 +45,12 @@ SQLite successor 已提交而 Git accept 尚未完成时,系统已经拥有 ca workspace 时必须从 SQLite head 找到 operation-bound candidate,严格重验 commit/tree/path/profile 后再 accept;不得 重新执行 Write/Edit。 +changed-path 集合不是内容因果证据。首版 Write/Edit 只允许一个 canonical path,T1 中的 `baseBlobOid` +证明 operation 所面对的 preimage(新建文件为 `null`);worker 的 `resultBlobOid` 证明同一份生产 transform +生成的 exact after-image;candidate receipt 则把该 digest 绑定到 immutable Git tree。外部进程无论在 worker +之前还是 worker 与 capture 之间修改同一路径,都会分别在 preimage 或 candidate blob 验证处 fail closed,不能 +仅因为 changed paths 仍然相同而进入 accepted successor。 + ## 3. 无副作用终态 T1 后只允许进入四种互斥状态: @@ -84,6 +91,7 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | T1 前 | 无 reservation | 明确失败,可重新 admission | | T1 后取消,worker 明确未产生 effect 且 Git clean | error T2 + terminal fact | 已收敛,head 不变 | | T1 后、worker 前/中,effect 不可证明 | reservation 保留 | park;M2.4 不自动猜测或覆盖 | +| 同路径外部写入导致 base/result blob mismatch | reservation 保留 | park/quarantine;不得吸收到 successor | | worker error 且 Git owner 证明 clean base | error T2 + terminal fact | 已收敛,head 不变 | | success 但 tree 无变化 | success T2 + terminal fact | 已收敛,head 不变 | | candidate capture 后、SQLite commit 前 | T1 + candidate artifact | reservation 保留;不得对外宣称成功 | diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index a00ae8e20b..be88dc4c61 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -451,6 +451,7 @@ describe('RuntimeEvent actions', () => { baseHeadRevision: 1, baseCommitOid: '1'.repeat(40), baseTreeOid: '2'.repeat(40), + baseBlobOid: '3'.repeat(40), expectedPaths: ['src/a.ts'], executionProfileDigest: `sha256:${'a'.repeat(64)}`, } as const; @@ -469,6 +470,21 @@ describe('RuntimeEvent actions', () => { .actions?.toolDispatch?.managedMutation, managedMutation, ); + assert.equal( + decodeRuntimeEvent( + baseEvent({ + role: 'system', + author: 'system', + actions: { + toolDispatch: { + ...toolDispatch, + managedMutation: { ...managedMutation, baseBlobOid: null }, + }, + }, + }), + ).actions?.toolDispatch?.managedMutation?.baseBlobOid, + null, + ); for (const invalid of [ { ...managedMutation, expectedPaths: ['src/../secrets.txt'] }, { ...managedMutation, expectedPaths: ['NoDe_MoDuLeS/pkg/index.js'] }, @@ -478,6 +494,7 @@ describe('RuntimeEvent actions', () => { { ...managedMutation, baseAcceptedEventId: 'event id with spaces' }, { ...managedMutation, baseHeadRevision: 0 }, { ...managedMutation, baseTreeOid: 'not-an-oid' }, + { ...managedMutation, baseBlobOid: 'not-an-oid' }, { ...managedMutation, extra: true }, ]) { assert.throws(() => diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index e33affbf4b..63b2566465 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -251,6 +251,8 @@ export interface RuntimeEventManagedWorkspaceMutationV1 { baseHeadRevision: number; baseCommitOid: string; baseTreeOid: string; + /** Git blob at expectedPaths[0] in baseTreeOid, or null when Write creates it. */ + baseBlobOid: string | null; expectedPaths: readonly string[]; executionProfileDigest: `sha256:${string}`; } @@ -573,6 +575,7 @@ const RUNTIME_MANAGED_WORKSPACE_MUTATION_SHAPE = 'baseHeadRevision', 'baseCommitOid', 'baseTreeOid', + 'baseBlobOid', 'expectedPaths', 'executionProfileDigest', ], @@ -880,6 +883,7 @@ function isRuntimeManagedWorkspaceMutation( value.baseHeadRevision < 1 || typeof value.baseCommitOid !== 'string' || typeof value.baseTreeOid !== 'string' || + (value.baseBlobOid !== null && typeof value.baseBlobOid !== 'string') || !isSha256Digest(value.executionProfileDigest) || !Array.isArray(value.expectedPaths) || value.expectedPaths.length === 0 || @@ -889,7 +893,13 @@ function isRuntimeManagedWorkspaceMutation( } const expectedPaths = value.expectedPaths; const oidPattern = value.objectFormat === 'sha1' ? /^[0-9a-f]{40}$/u : /^[0-9a-f]{64}$/u; - if (!oidPattern.test(value.baseCommitOid) || !oidPattern.test(value.baseTreeOid)) return false; + if ( + !oidPattern.test(value.baseCommitOid) || + !oidPattern.test(value.baseTreeOid) || + (value.baseBlobOid !== null && !oidPattern.test(value.baseBlobOid)) + ) { + return false; + } return ( new Set(expectedPaths).size === expectedPaths.length && expectedPaths.every(isCanonicalManagedMutationPathV1) && diff --git a/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts b/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts index eb033e1e80..3a6bf8ba67 100644 --- a/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts +++ b/packages/runtime-host/src/__tests__/managed-workspace-mutation-session.test.ts @@ -63,6 +63,7 @@ test('binds read and mutation operations to one owner-issued managed handle', as ok: true as const, path: operation.path, bytes: 7, + resultBlobOid: '1'.repeat(40), }; }, async openManagedWorkspaceBaseline() { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 77800abe93..499ee48625 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1779,11 +1779,18 @@ function adaptManagedWorkspaceFilesystemWorker( switch (result.kind) { case 'read': case 'read_image': - case 'write': - case 'edit': case 'glob': case 'grep': return result; + case 'write': + case 'edit': + if (!result.resultBlobOid) { + throw new RuntimeHostWorkspaceExecutionError( + 'workspace_operation_denied', + 'Managed mutation worker omitted its exact result blob identity', + ); + } + return { ...result, resultBlobOid: result.resultBlobOid }; default: throw new RuntimeHostWorkspaceExecutionError( 'workspace_operation_denied', diff --git a/packages/runtime/src/__tests__/filesystem-worker.test.ts b/packages/runtime/src/__tests__/filesystem-worker.test.ts index a7e077e307..f26ff2522d 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -1,4 +1,5 @@ import { strict as assert } from 'node:assert'; +import { createHash } from 'node:crypto'; import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, parse } from 'node:path'; @@ -405,6 +406,50 @@ describe('filesystem worker operations', () => { assert.equal(response.result.diff, undefined); }); + test('binds managed Edit to its exact base blob and returns the exact result blob', async () => { + const root = await temporaryDirectory('maka-worker-managed-edit-'); + const target = join(root, 'tracked.txt'); + await writeFile(target, 'tracked\nEXTERNAL\n', 'utf8'); + + const drifted = await executeFilesystemWorkerRequest( + requestFor( + { + kind: 'edit', + cwd: root, + path: target, + oldString: 'tracked', + newString: 'updated', + }, + { enforcementPath: target, access: 'write', scope: 'exact', targetType: 'file' }, + target, + { objectFormat: 'sha1', baseBlobOid: gitBlobOid('tracked\n') }, + ), + ); + assert.equal(drifted.ok, false); + if (!drifted.ok) assert.equal(drifted.error.code, 'path_changed'); + assert.equal(await readFile(target, 'utf8'), 'tracked\nEXTERNAL\n'); + + await writeFile(target, 'tracked\n', 'utf8'); + const accepted = await executeFilesystemWorkerRequest( + requestFor( + { + kind: 'edit', + cwd: root, + path: target, + oldString: 'tracked', + newString: 'updated', + }, + { enforcementPath: target, access: 'write', scope: 'exact', targetType: 'file' }, + target, + { objectFormat: 'sha1', baseBlobOid: gitBlobOid('tracked\n') }, + ), + ); + assert.equal(accepted.ok, true); + if (!accepted.ok || accepted.result.kind !== 'edit') return; + assert.equal(accepted.result.resultBlobOid, gitBlobOid('updated\n')); + assert.equal(await readFile(target, 'utf8'), 'updated\n'); + }); + test('omits the diff when FormatJson leaves the file unchanged', async () => { const root = await temporaryDirectory('maka-worker-format-same-'); const target = join(root, 'data.json'); @@ -467,6 +512,7 @@ function requestFor( operation: FilesystemWorkerOperation, expectedTarget: FilesystemWorkerTarget, permissionPath = operation.path, + mutationEvidence?: FilesystemWorkerRequest['mutationEvidence'], ): FilesystemWorkerRequest { const operationBoundary: FilesystemWorkerRequest['operationBoundary'] = { filesystem: { @@ -485,9 +531,18 @@ function requestFor( operation, operationBoundary, expectedTarget, + ...(mutationEvidence ? { mutationEvidence } : {}), }; } +function gitBlobOid(content: string): string { + const bytes = Buffer.from(content, 'utf8'); + return createHash('sha1') + .update(`blob ${bytes.byteLength}\0`, 'utf8') + .update(bytes) + .digest('hex'); +} + async function temporaryDirectory(prefix: string): Promise { const path = await mkdtemp(join(tmpdir(), prefix)); cleanup.push(path); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 77aa39be38..7080626db5 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1705,6 +1705,7 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { baseHeadRevision: 1, baseCommitOid: '1'.repeat(40), baseTreeOid: '2'.repeat(40), + baseBlobOid: '3'.repeat(40), expectedPaths: ['tracked.txt'], executionProfileDigest: `sha256:${'6'.repeat(64)}`, }, diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index b40a731ec1..e10cd247df 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -1711,6 +1711,7 @@ function managedMutationDispatch() { baseHeadRevision: 1, baseCommitOid: '1'.repeat(40), baseTreeOid: '2'.repeat(40), + baseBlobOid: '3'.repeat(40), expectedPaths: ['notes.txt'], executionProfileDigest: `sha256:${'a'.repeat(64)}` as const, }; diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index 14db96891d..0516254238 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -58,6 +58,10 @@ export interface FilesystemWorkerExecuteInput { /** Explicit embedding policy. Mode-based defaults are compiled only when omitted. */ permissionProfile?: PermissionProfile; abortSignal?: AbortSignal; + mutationEvidence?: { + readonly objectFormat: 'sha1' | 'sha256'; + readonly baseBlobOid: string | null; + }; } export type FilesystemWorkerClientErrorReason = @@ -247,6 +251,7 @@ export class FilesystemWorkerClient { scope: target.scope, targetType: target.targetType, }, + ...(input.mutationEvidence ? { mutationEvidence: input.mutationEvidence } : {}), } as const; const requestJson = JSON.stringify(request); if (Buffer.byteLength(requestJson, 'utf8') > FILESYSTEM_WORKER_MAX_REQUEST_BYTES) { diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 2bf5c56255..50cd377c91 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import { glob as nodeGlob } from 'node:fs/promises'; import { dirname, isAbsolute, parse, resolve } from 'node:path'; @@ -78,6 +79,7 @@ export async function executeFilesystemWorkerRequest( request.operation, request.operationBoundary, dependencies, + request.mutationEvidence, ), }; } catch (error) { @@ -95,6 +97,7 @@ export async function executeFilesystemOperation( operation: FilesystemWorkerOperation, operationBoundary: FilesystemWorkerRequest['operationBoundary'], dependencies: FilesystemWorkerOperationDependencies = {}, + mutationEvidence?: FilesystemWorkerRequest['mutationEvidence'], ): Promise { switch (operation.kind) { case 'read': { @@ -140,13 +143,19 @@ export async function executeFilesystemOperation( // any other read failure leaves the previous state unknown, and // claiming `--- /dev/null` would report the file as created. let previous: 'new' | 'unknown' | string; + let previousBytes: Buffer | undefined; try { - previous = await fs.readFile(path, 'utf8'); + previousBytes = await fs.readFile(path); + previous = previousBytes.toString('utf8'); } catch (error) { const code = (error as NodeJS.ErrnoException).code; previous = code === 'ENOENT' || code === 'ENOTDIR' ? 'new' : 'unknown'; } + assertManagedMutationPreimage(previousBytes, mutationEvidence); await fs.writeFile(path, operation.content, 'utf8'); + const resultBlobOid = mutationEvidence + ? gitBlobOid(Buffer.from(operation.content, 'utf8'), mutationEvidence.objectFormat) + : undefined; const diff = previous === 'unknown' ? undefined @@ -157,6 +166,7 @@ export async function executeFilesystemOperation( path, bytes: Buffer.byteLength(operation.content, 'utf8'), ...(diff !== undefined ? { diff } : {}), + ...(resultBlobOid ? { resultBlobOid } : {}), }; } case 'apply_patch': { @@ -189,7 +199,9 @@ export async function executeFilesystemOperation( 'write', operationBoundary, ); - const content = await fs.readFile(path, 'utf8'); + const contentBytes = await fs.readFile(path); + assertManagedMutationPreimage(contentBytes, mutationEvidence); + const content = contentBytes.toString('utf8'); let edited: ReturnType; try { edited = computeEditedSource( @@ -205,6 +217,9 @@ export async function executeFilesystemOperation( ); } await fs.writeFile(path, edited.content, 'utf8'); + const resultBlobOid = mutationEvidence + ? gitBlobOid(Buffer.from(edited.content, 'utf8'), mutationEvidence.objectFormat) + : undefined; const diff = createUnifiedDiff(path, content, edited.content); return { kind: 'edit', @@ -215,6 +230,7 @@ export async function executeFilesystemOperation( startLine: edited.startLine, endLine: edited.endLine, ...(diff !== undefined ? { diff } : {}), + ...(resultBlobOid ? { resultBlobOid } : {}), }; } case 'format_json': { @@ -526,6 +542,32 @@ async function lstatTargetTypeOf(path: string): Promise { + if ( + evidence.baseBlobOid !== null && + evidence.baseBlobOid.length !== (evidence.objectFormat === 'sha1' ? 40 : 64) + ) { + context.addIssue({ code: 'custom', message: 'Git blob OID does not match object format' }); + } + }); const OperationBoundarySchema = z .object({ @@ -113,8 +130,21 @@ export const FilesystemWorkerRequestSchema = z operation: FilesystemWorkerOperationSchema, operationBoundary: OperationBoundarySchema, expectedTarget: FilesystemWorkerTargetSchema, + mutationEvidence: ManagedMutationEvidenceSchema.optional(), }) - .strict(); + .strict() + .superRefine((request, context) => { + if ( + request.mutationEvidence !== undefined && + request.operation.kind !== 'write' && + request.operation.kind !== 'edit' + ) { + context.addIssue({ + code: 'custom', + message: 'Mutation evidence is permitted only for Write/Edit operations', + }); + } + }); export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('read'), content: z.string() }).strict(), @@ -132,6 +162,7 @@ export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ path: z.string(), bytes: z.number().int().nonnegative(), diff: z.string().optional(), + resultBlobOid: GitBlobOidSchema.optional(), }) .strict(), z.object({ kind: z.literal('apply_patch'), ok: z.literal(true), path: z.string() }).strict(), @@ -145,6 +176,7 @@ export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ startLine: z.number().int().positive(), endLine: z.number().int().positive(), diff: z.string().optional(), + resultBlobOid: GitBlobOidSchema.optional(), }) .strict(), z diff --git a/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts b/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts index f1eddd6204..60c603d29e 100644 --- a/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts +++ b/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts @@ -1,4 +1,5 @@ import { writeFileSync, writeSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { join } from 'node:path'; import { createGitWorkspaceService } from '../../git-workspace-service.js'; import { requireManagedBaselineReceiptAuthorityInternal } from '../../managed-baseline-receipt-authority-internal.js'; @@ -69,6 +70,7 @@ if (process.env.MAKA_GIT_WORKSPACE_ACTION === 'managed-mutation-owner') { ok: true as const, path, bytes: Buffer.byteLength(input.operation.content, 'utf8'), + resultBlobOid: gitBlobOid(input.operation.content), }; }, }, @@ -157,6 +159,7 @@ if ( revision: 1, }, expectedPaths: ['docs/a.md'], + expectedBlobOid: gitBlobOid('candidate from child\n'), executionProfileDigest: `sha256:${'e'.repeat(64)}` as const, }; writeFileSync(join(binding.worktreePath, 'docs', 'a.md'), 'candidate from child\n', 'utf8'); @@ -190,6 +193,14 @@ function requiredEnv(name: string): string { return value; } +function gitBlobOid(content: string): string { + const bytes = Buffer.from(content, 'utf8'); + return createHash('sha1') + .update(`blob ${bytes.byteLength}\0`, 'utf8') + .update(bytes) + .digest('hex'); +} + async function commitManagedMutationT1( store: ReturnType, managedMutation: Readonly, diff --git a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts index ffc0d4bf37..c03fc45164 100644 --- a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts +++ b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts @@ -353,6 +353,7 @@ function managedMutationPreparedCommit(variant: 'a' | 'b') { baseHeadRevision: 1, baseCommitOid: '5'.repeat(40), baseTreeOid: '2'.repeat(40), + baseBlobOid: '3'.repeat(40), expectedPaths: ['notes.txt'], executionProfileDigest: `sha256:${'a'.repeat(64)}` as const, }, diff --git a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts index 813384c4fd..1a0b421438 100644 --- a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts +++ b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts @@ -151,7 +151,13 @@ describe('managed mutation candidate authority', () => { } const receipt = await requireManagedMutationCandidateAuthorityInternal(service).capture( - candidateRequest(binding, baseline, [relativePath], `operation-nested-${mutation}`), + candidateRequest( + binding, + baseline, + [relativePath], + `operation-nested-${mutation}`, + mutation === 'delete' ? null : `${mutation}\n`, + ), ); assert.deepEqual(receipt.changedPaths, [relativePath]); @@ -357,6 +363,7 @@ function candidateRequest( >, expectedPaths: readonly string[] = ['tracked.txt'], operationId = 'operation-candidate-1', + expectedContent: string | null = 'candidate\n', ) { return { binding, @@ -372,10 +379,19 @@ function candidateRequest( revision: 1, }, expectedPaths, + expectedBlobOid: expectedContent === null ? null : gitBlobOid(expectedContent), executionProfileDigest: `sha256:${'e'.repeat(64)}`, } as const; } +function gitBlobOid(content: string): string { + const bytes = Buffer.from(content, 'utf8'); + return createHash('sha1') + .update(`blob ${bytes.byteLength}\0`, 'utf8') + .update(bytes) + .digest('hex'); +} + async function serviceAt(storageRoot: string): Promise { return createGitWorkspaceService({ storageRoot, diff --git a/packages/storage/src/__tests__/managed-workspace-owner.test.ts b/packages/storage/src/__tests__/managed-workspace-owner.test.ts index 8b75dd104a..5ff17ee8b4 100644 --- a/packages/storage/src/__tests__/managed-workspace-owner.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-owner.test.ts @@ -264,6 +264,7 @@ test('freezes canonical Write/Edit admission from the owner-bound head and worke baseHeadRevision: accepted.head.revision, baseCommitOid: accepted.head.commitOid, baseTreeOid: accepted.head.treeOid, + baseBlobOid: null, expectedPaths: ['dir/tracked.txt'], executionProfileDigest: admission.durableDispatch.executionProfileDigest, }); @@ -276,14 +277,15 @@ test('freezes canonical Write/Edit admission from the owner-bound head and worke operationId: 'operation-managed-edit-1', toolName: 'Edit', persistedArgs: { - path: inputPath, - old_string: 'before', + path: 'tracked.txt', + old_string: 'tracked', new_string: 'after', }, abortSignal: new AbortController().signal, }); - assert.deepEqual(editAdmission.durableDispatch.expectedPaths, ['dir/tracked.txt']); - assert.equal(editAdmission.canonicalPath, 'dir/tracked.txt'); + assert.deepEqual(editAdmission.durableDispatch.expectedPaths, ['tracked.txt']); + assert.equal(editAdmission.canonicalPath, 'tracked.txt'); + assert.equal(editAdmission.durableDispatch.baseBlobOid, gitBlobOid('tracked\n')); await editAdmission.dispose(); await owner.close(); } finally { @@ -322,6 +324,7 @@ test('accepts a worker-owned Write only after capturing its Git candidate', asyn ok: true as const, path: target, bytes: Buffer.byteLength(input.operation.content, 'utf8'), + resultBlobOid: gitBlobOid(input.operation.content), }; }, }, @@ -455,6 +458,104 @@ test('accepts a worker-owned Write only after capturing its Git candidate', asyn } }); +test('rejects an Edit candidate that incorporates same-path external content after T1', async () => { + const root = await temporaryRoot(); + const storageRoot = join(root, 'storage'); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const capability = await resolveStorageRoot({ path: storageRoot, kind: 'interactive' }); + const rootOwner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(rootOwner); + const runtimeStore = createSqliteRuntimeStore(join(storageRoot, 'runtime.sqlite')); + const operationId = 'operation-managed-edit-same-path-drift'; + const toolCallId = 'call-managed-edit-same-path-drift'; + const args = { path: 'tracked.txt', old_string: 'tracked', new_string: 'updated' }; + try { + const owner = await openManagedWorkspaceOwner({ + rootOwner, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + filesystemWorker: { + mutationExecutionProfileDigest: TEST_MUTATION_PROFILE, + async execute(input) { + assert.equal(input.operation.kind, 'edit'); + if (input.operation.kind !== 'edit') throw new Error('expected Edit'); + const target = join(input.cwd, input.operation.path); + const before = await readFile(target, 'utf8'); + const after = before.replace(input.operation.oldString, input.operation.newString); + await writeFile(target, after, 'utf8'); + return { + kind: 'edit' as const, + ok: true as const, + path: target, + replacements: 1 as const, + matchedVia: 'exact' as const, + startLine: 1, + endLine: 1, + // The worker proof names only this operation's exact transform; + // the candidate contains an externally injected suffix and must fail. + resultBlobOid: gitBlobOid('updated\n'), + }; + }, + }, + }); + const accepted = await owner.openManagedWorkspaceBaseline( + runtimeStore, + openRequest(sourceRoot), + ); + const admission = await owner.admitManagedWorkspaceMutation(accepted.executionHandle, { + operationId, + toolName: 'Edit', + persistedArgs: args, + abortSignal: new AbortController().signal, + }); + await commitManagedMutationT1(runtimeStore, admission.durableDispatch, { + operationId, + toolCallId, + toolName: 'Edit', + args, + }); + const { binding } = inspectManagedWorkspaceExecutionHandleInternal(accepted.executionHandle); + await writeFile(join(binding.worktreePath, args.path), 'tracked\nEXTERNAL\n', 'utf8'); + + await assert.rejects( + admission.execute(async () => { + const result = await owner.executeManagedMutationFilesystemOperation( + { + kind: 'edit', + path: args.path, + oldString: args.old_string, + newString: args.new_string, + }, + new AbortController().signal, + ); + const content = { kind: 'json' as const, value: result }; + return { + content, + isError: false, + durationMs: 1, + durableOutcome: managedMutationOutcome(operationId, toolCallId, content, false, 'Edit'), + }; + }), + /worker result blob/u, + ); + assert.equal( + ( + await runtimeStore.readWorkspaceHead( + accepted.head.workspaceId, + accepted.head.workspaceEpochId, + ) + )?.revision, + 1, + ); + await owner.close(); + } finally { + runtimeStore.close(); + await rootOwner.close(); + } +}); + test('atomically settles post-T1 failure and successful no-effect Writes without advancing the head', async () => { const root = await temporaryRoot(); const storageRoot = join(root, 'storage'); @@ -485,6 +586,7 @@ test('atomically settles post-T1 failure and successful no-effect Writes without ok: true as const, path: target, bytes: Buffer.byteLength(input.operation.content, 'utf8'), + resultBlobOid: gitBlobOid(input.operation.content), }; }, }, @@ -1427,7 +1529,10 @@ async function commitManagedMutationT1( input: { operationId: string; toolCallId: string; - args: { path: string; content: string }; + toolName?: 'Write' | 'Edit'; + args: + | { path: string; content: string } + | { path: string; old_string: string; new_string: string }; }, ): Promise { const identity = { @@ -1436,7 +1541,8 @@ async function commitManagedMutationT1( runId: 'run-managed-mutation', turnId: 'turn-managed-mutation', }; - const canonicalArgsHash = canonicalToolArgsHash('Write', input.args); + const toolName = input.toolName ?? 'Write'; + const canonicalArgsHash = canonicalToolArgsHash(toolName, input.args); await store.commitToolPrepared({ operationId: input.operationId, journalEventId: `${input.operationId}_prepared`, @@ -1450,7 +1556,7 @@ async function commitManagedMutationT1( content: { kind: 'function_call', id: input.toolCallId, - name: 'Write', + name: toolName, args: input.args, }, refs: { operationId: input.operationId, toolCallId: input.toolCallId }, @@ -1467,7 +1573,7 @@ async function commitManagedMutationT1( protocol: 't1_after_preflight_v1', operationId: input.operationId, providerToolCallId: input.toolCallId, - toolName: 'Write', + toolName, canonicalArgsHash, recoveryMode: 'reconcile', managedMutation, @@ -1476,7 +1582,7 @@ async function commitManagedMutationT1( refs: { operationId: input.operationId, toolCallId: input.toolCallId }, }, providerToolCallId: input.toolCallId, - toolName: 'Write', + toolName, canonicalArgsHash, recoveryMode: 'reconcile', committedAt: 10, @@ -1488,6 +1594,7 @@ function managedMutationOutcome( toolCallId: string, result: unknown, isError = false, + toolName: 'Write' | 'Edit' = 'Write', ): RuntimeEvent { return { id: `${operationId}_response`, @@ -1504,7 +1611,7 @@ function managedMutationOutcome( content: { kind: 'function_response', id: toolCallId, - name: 'Write', + name: toolName, result, ...(isError ? { isError: true } : {}), }, @@ -1513,6 +1620,14 @@ function managedMutationOutcome( }; } +function gitBlobOid(content: string): string { + const bytes = Buffer.from(content, 'utf8'); + return createHash('sha1') + .update(`blob ${bytes.byteLength}\0`, 'utf8') + .update(bytes) + .digest('hex'); +} + async function git(cwd: string, ...args: string[]): Promise { const { stdout } = await execFileAsync('git', args, { cwd, diff --git a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts index 863abef9be..c3872092eb 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-crash.test.ts @@ -432,6 +432,7 @@ function workspaceSuccessorPreparedCommit(operationId = 'workspace-successor-ope baseHeadRevision: 1, baseCommitOid: '5'.repeat(40), baseTreeOid: '2'.repeat(40), + baseBlobOid: '3'.repeat(40), expectedPaths: ['notes.txt'], executionProfileDigest: `sha256:${'a'.repeat(64)}` as const, }, diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index 3c99bffb95..929d504717 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -946,6 +946,7 @@ async function prepareSuccessorCommit( baseHeadRevision: opened.head.revision, baseCommitOid: opened.head.commitOid, baseTreeOid: opened.head.treeOid, + baseBlobOid: null, expectedPaths: ['notes.txt'], executionProfileDigest: `sha256:${'a'.repeat(64)}`, }, @@ -1071,6 +1072,7 @@ function managedPreparedCommit( baseHeadRevision: head.revision, baseCommitOid: head.commitOid, baseTreeOid: head.treeOid, + baseBlobOid: null, expectedPaths: ['notes.txt'], executionProfileDigest: `sha256:${'a'.repeat(64)}` as const, }, diff --git a/packages/storage/src/git-workspace-service.ts b/packages/storage/src/git-workspace-service.ts index 849d7266b9..6fbec27b2c 100644 --- a/packages/storage/src/git-workspace-service.ts +++ b/packages/storage/src/git-workspace-service.ts @@ -14,6 +14,7 @@ import { import { dirname, isAbsolute, join, normalize, posix, relative, resolve } from 'node:path'; import { isDeepStrictEqual, promisify } from 'node:util'; import { isCanonicalManagedMutationPathV1 } from '@maka/core/runtime-event'; +import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; import { withArtifactWriterLock } from './artifact-writer-lock.js'; import { bundledGitEnvironment } from './dugite-native-environment.js'; import { registerManagedBaselineReceiptAuthorityInternal } from './managed-baseline-receipt-authority-internal.js'; @@ -128,6 +129,7 @@ const MUTATION_CANDIDATE_RECEIPT_KEYS = [ 'treeDeltaDigest', 'changedPaths', 'deletedPaths', + 'expectedBlobOid', 'executionProfileDigest', ] as const; const MUTATION_CANDIDATE_POLICY_V1 = { @@ -138,6 +140,7 @@ const MUTATION_CANDIDATE_POLICY_V1 = { symlinks: 'reject', submodules: 'reject', renames: 'reject', + content: 'exact_worker_result_blob', commitParents: 'exactly_one', } as const; const MUTATION_CANDIDATE_POLICY_HASH_V1 = hashCanonicalJson(MUTATION_CANDIDATE_POLICY_V1); @@ -428,6 +431,8 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { verify: (receipt) => this.#verifyManagedWorkspaceBaselineReceipt(receipt), }); registerManagedMutationCandidateAuthorityInternal(this, { + readBaseBlob: (binding, baseHead, path) => + this.#readManagedMutationBaseBlob(binding, baseHead, path), capture: (request) => this.#captureManagedMutationCandidate(request), require: (binding, operationId) => this.#requireManagedMutationCandidate(binding, operationId), @@ -806,6 +811,13 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } await this.assertSupportedMutationCandidateTree(binding, candidateTreeOid, layout); + await this.assertManagedMutationCandidateBlob( + binding, + candidateTreeOid, + request.expectedPaths[0]!, + request.expectedBlobOid, + layout, + ); const candidateCommitOid = ( await this.runtime.run( [ @@ -862,6 +874,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { treeDeltaDigest: delta.treeDeltaDigest, changedPaths: delta.changedPaths, deletedPaths: delta.deletedPaths, + expectedBlobOid: request.expectedBlobOid, executionProfileDigest: request.executionProfileDigest, }; await atomicWriteJson(receiptPath, receipt); @@ -880,6 +893,51 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { }); } + async #readManagedMutationBaseBlob( + binding: ManagedWorkspaceBinding, + baseHead: WorkspaceHeadRecordV1, + path: string, + ): Promise { + const canonicalPath = assertManagedMutationPath(path); + const runtime = await this.runtime.verify(); + return await withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { + const layout = workspaceLayout(canonicalStorageRoot, binding); + const summary = await this.requireVerifiedMutationContext(binding, layout, runtime.digest); + const baselineReceipt = await readBaselineReceipt(layout.baselineReceiptPath); + if (!baselineReceipt) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Canonical workspace baseline receipt is unavailable', + ); + } + assertBaselineReceiptMatches(baselineReceipt, binding, summary); + assertCandidateBaseMatches(binding, baselineReceipt, baseHead); + const entries = parseTreeEntries( + await this.runtime.runBuffer( + [ + '--literal-pathspecs', + '--git-dir', + binding.repositoryPath, + 'ls-tree', + '-z', + baseHead.treeOid, + '--', + canonicalPath, + ], + layout.homePath, + ), + ).filter((entry) => entry.path === canonicalPath); + if (entries.length === 0) return null; + if (entries.length !== 1 || entries[0]!.objectType !== 'blob') { + throw new GitWorkspaceServiceError( + 'managed_mutation_candidate_rejected', + 'Managed mutation base path is not one regular Git blob', + ); + } + return entries[0]!.oid; + }); + } + async #discardManagedMutationCandidate(input: ManagedMutationCandidateReceiptV1): Promise { const receipt = snapshotMutationCandidateReceipt(input); await withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { @@ -986,6 +1044,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { operationId: receipt.operationId, baseHead: receipt.baseHead, expectedPaths: receipt.changedPaths, + expectedBlobOid: receipt.expectedBlobOid, executionProfileDigest: receipt.executionProfileDigest, }, identity.ref, @@ -1114,6 +1173,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { operationId, baseHead: receipt.baseHead, expectedPaths: receipt.changedPaths, + expectedBlobOid: receipt.expectedBlobOid, executionProfileDigest: receipt.executionProfileDigest, }, identity.ref, @@ -1351,6 +1411,13 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { receipt.candidateTreeOid, layout, ); + await this.assertManagedMutationCandidateBlob( + storedBinding, + receipt.candidateTreeOid, + receipt.changedPaths[0]!, + receipt.expectedBlobOid, + layout, + ); const delta = await this.readMutationDelta( storedBinding, receipt.baseHead.commitOid, @@ -1369,6 +1436,42 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { } } + private async assertManagedMutationCandidateBlob( + binding: ManagedWorkspaceBinding, + treeOid: string, + path: string, + expectedBlobOid: string | null, + layout: WorkspaceLayout, + ): Promise { + const entries = parseTreeEntries( + await this.runtime.runBuffer( + [ + '--literal-pathspecs', + '--git-dir', + binding.repositoryPath, + 'ls-tree', + '-z', + treeOid, + '--', + path, + ], + layout.homePath, + ), + ).filter((entry) => entry.path === path); + const matches = + expectedBlobOid === null + ? entries.length === 0 + : entries.length === 1 && + entries[0]!.objectType === 'blob' && + entries[0]!.oid === expectedBlobOid; + if (!matches) { + throw new GitWorkspaceServiceError( + 'managed_mutation_candidate_rejected', + 'Managed mutation candidate does not match its worker result blob', + ); + } + } + private async assertSupportedMutationCandidateTree( binding: ManagedWorkspaceBinding, treeOid: string, @@ -3100,6 +3203,7 @@ function assertMutationCandidateRequest(request: ManagedMutationCandidateRequest 'operationId', 'baseHead', 'expectedPaths', + 'expectedBlobOid', 'executionProfileDigest', ]) ) { @@ -3112,9 +3216,10 @@ function assertMutationCandidateRequest(request: ManagedMutationCandidateRequest if ( !/^[A-Za-z0-9_-]{1,128}$/u.test(request.operationId) || !SHA256_PATTERN.test(request.executionProfileDigest) || + (request.expectedBlobOid !== null && + !oidMatchesObjectFormat(request.expectedBlobOid, request.binding.objectFormat)) || !isWorkspaceHeadRecord(request.baseHead) || - request.expectedPaths.length === 0 || - request.expectedPaths.length > 32 + request.expectedPaths.length !== 1 ) { throw new GitWorkspaceServiceError( 'managed_workspace_identity_conflict', @@ -3241,8 +3346,12 @@ function isMutationCandidateReceipt(value: unknown): value is ManagedMutationCan typeof value.treeDeltaDigest === 'string' && SHA256_PATTERN.test(value.treeDeltaDigest) && isCanonicalPathArray(value.changedPaths, false) && + value.changedPaths.length === 1 && isCanonicalPathArray(value.deletedPaths, true) && isPathSubset(value.deletedPaths, value.changedPaths) && + (value.expectedBlobOid === null || + (typeof value.expectedBlobOid === 'string' && + oidMatchesObjectFormat(value.expectedBlobOid, value.objectFormat))) && typeof value.executionProfileDigest === 'string' && SHA256_PATTERN.test(value.executionProfileDigest) ); @@ -3268,6 +3377,7 @@ function assertMutationCandidateReceiptMatches( receipt.workspacePolicyHash !== workspacePolicyHash || !isDeepStrictEqual(receipt.baseHead, request.baseHead) || !sameStringSet(receipt.changedPaths, request.expectedPaths) || + receipt.expectedBlobOid !== request.expectedBlobOid || receipt.executionProfileDigest !== request.executionProfileDigest ) { throw new GitWorkspaceServiceError( diff --git a/packages/storage/src/managed-mutation-candidate-authority-internal.ts b/packages/storage/src/managed-mutation-candidate-authority-internal.ts index f69f5088ea..d94e8a06bc 100644 --- a/packages/storage/src/managed-mutation-candidate-authority-internal.ts +++ b/packages/storage/src/managed-mutation-candidate-authority-internal.ts @@ -6,6 +6,8 @@ export interface ManagedMutationCandidateRequest { readonly operationId: string; readonly baseHead: WorkspaceHeadRecordV1; readonly expectedPaths: readonly string[]; + /** Exact resulting blob, or null when the sole declared path is deleted. */ + readonly expectedBlobOid: string | null; readonly executionProfileDigest: `sha256:${string}`; } @@ -29,10 +31,16 @@ export interface ManagedMutationCandidateReceiptV1 { readonly treeDeltaDigest: `sha256:${string}`; readonly changedPaths: readonly string[]; readonly deletedPaths: readonly string[]; + readonly expectedBlobOid: string | null; readonly executionProfileDigest: `sha256:${string}`; } export interface ManagedMutationCandidateAuthorityInternal { + readBaseBlob( + binding: ManagedWorkspaceBinding, + baseHead: WorkspaceHeadRecordV1, + path: string, + ): Promise; capture(request: ManagedMutationCandidateRequest): Promise; require( binding: ManagedWorkspaceBinding, diff --git a/packages/storage/src/managed-workspace-execution-authority-internal.ts b/packages/storage/src/managed-workspace-execution-authority-internal.ts index 08946236df..797b69ead8 100644 --- a/packages/storage/src/managed-workspace-execution-authority-internal.ts +++ b/packages/storage/src/managed-workspace-execution-authority-internal.ts @@ -72,6 +72,8 @@ interface ManagedWorkspaceMutationScopeStateInternal { readonly head: Readonly; readonly operationId: string; readonly expectedPaths: readonly string[]; + readonly objectFormat: 'sha1' | 'sha256'; + readonly baseBlobOid: string | null; } export type ManagedWorkspaceExecutionScopeStateInternal = diff --git a/packages/storage/src/managed-workspace-owner.ts b/packages/storage/src/managed-workspace-owner.ts index 2bc0065cb6..29eaac1d2c 100644 --- a/packages/storage/src/managed-workspace-owner.ts +++ b/packages/storage/src/managed-workspace-owner.ts @@ -152,6 +152,11 @@ export type ManagedWorkspaceMutationSettlement = readonly durableOutcome: import('@maka/core/runtime-event').RuntimeEvent; }; +interface ManagedMutationExecutionContext { + readonly scope: ManagedWorkspaceExecutionScope; + resultBlobOid?: string; +} + export interface ManagedWorkspaceOwner { readonly state: 'ready' | 'closing' | 'closed'; openManagedWorkspaceBaseline( @@ -247,7 +252,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { readonly #drainWaiters = new Set<() => void>(); readonly #assertCurrentRootIdentity: () => Promise; readonly #executionContext = new AsyncLocalStorage(); - readonly #mutationExecutionContext = new AsyncLocalStorage(); + readonly #mutationExecutionContext = new AsyncLocalStorage(); #closeTask: Promise | undefined; readonly #executionOwnerToken = {}; readonly #workerBridge: ManagedWorkspaceWorkerBridgeInternal | undefined; @@ -554,6 +559,17 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { ); } const canonicalPath = canonicalManagedMutationPath(input.toolName, input.persistedArgs); + const baseBlobOid = await this.mutationCandidateAuthority.readBaseBlob( + accepted.binding, + currentHead, + canonicalPath, + ); + if (input.toolName === 'Edit' && baseBlobOid === null) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Managed Edit target is absent from the admitted base tree', + ); + } input.abortSignal.throwIfAborted(); const durableDispatch = Object.freeze({ protocol: 'managed_mutation_v1' as const, @@ -567,6 +583,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { baseHeadRevision: currentHead.revision, baseCommitOid: currentHead.commitOid, baseTreeOid: currentHead.treeOid, + baseBlobOid, expectedPaths: Object.freeze([canonicalPath]), executionProfileDigest: this.#workerBridge.mutationExecutionProfileDigest, }); @@ -596,10 +613,13 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { head: freezeWorkspaceHead(currentHead), operationId: input.operationId, expectedPaths: durableDispatch.expectedPaths, + objectFormat: accepted.binding.objectFormat, + baseBlobOid, }); try { + const executionContext: ManagedMutationExecutionContext = { scope }; const proof = await this.#executionContext.run(this.#executionOwnerToken, () => - this.#mutationExecutionContext.run(scope, operation), + this.#mutationExecutionContext.run(executionContext, operation), ); if (proof.isError) { await this.#requireReady(accepted.binding); @@ -617,6 +637,12 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { durableOutcome: proof.durableOutcome, }); } + if (!executionContext.resultBlobOid) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Managed mutation completed without an exact worker result blob', + ); + } let candidate: ManagedMutationCandidateReceiptV1; try { candidate = await this.mutationCandidateAuthority.capture({ @@ -624,6 +650,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { operationId: input.operationId, baseHead: currentHead, expectedPaths: durableDispatch.expectedPaths, + expectedBlobOid: executionContext.resultBlobOid, executionProfileDigest: durableDispatch.executionProfileDigest, }); } catch (error) { @@ -633,6 +660,12 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { ) { throw error; } + if (executionContext.resultBlobOid !== baseBlobOid) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_owner_unavailable', + 'Managed mutation no-change state conflicts with its worker result blob', + ); + } await this.#requireReady(accepted.binding); await commitManagedMutationTerminalInternal(accepted.store, { disposition: 'no_workspace_change_committed', @@ -740,14 +773,22 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { 'Managed workspace filesystem worker is unavailable', ); } - const scope = this.#mutationExecutionContext.getStore(); - if (!scope) { + const context = this.#mutationExecutionContext.getStore(); + if (!context) { throw new ManagedWorkspaceOwnerError( 'managed_workspace_execution_handle_invalid', 'Managed mutation worker is available only inside its active admission', ); } - return await this.#workerBridge.executeMutation(scope, operation, abortSignal); + if (context.resultBlobOid) { + throw new ManagedWorkspaceOwnerError( + 'managed_workspace_execution_handle_invalid', + 'Managed mutation worker result was already consumed', + ); + } + const result = await this.#workerBridge.executeMutation(context.scope, operation, abortSignal); + context.resultBlobOid = result.resultBlobOid; + return result; } close(): Promise { @@ -933,6 +974,7 @@ function assertManagedMutationReservation( reservation.baseHeadRevision !== dispatch.baseHeadRevision || reservation.baseCommitOid !== dispatch.baseCommitOid || reservation.baseTreeOid !== dispatch.baseTreeOid || + reservation.baseBlobOid !== dispatch.baseBlobOid || reservation.executionProfileDigest !== dispatch.executionProfileDigest || reservation.expectedPaths.length !== dispatch.expectedPaths.length || reservation.expectedPaths.some((path, index) => path !== dispatch.expectedPaths[index]) diff --git a/packages/storage/src/managed-workspace-worker-bridge-internal.ts b/packages/storage/src/managed-workspace-worker-bridge-internal.ts index 33c333fb57..3b2bdc49a1 100644 --- a/packages/storage/src/managed-workspace-worker-bridge-internal.ts +++ b/packages/storage/src/managed-workspace-worker-bridge-internal.ts @@ -56,6 +56,10 @@ interface ManagedWorkspaceFilesystemWorkerInput { readonly cwd: string; readonly executionBoundary: ExecutionBoundary; readonly abortSignal?: AbortSignal; + readonly mutationEvidence?: { + readonly objectFormat: 'sha1' | 'sha256'; + readonly baseBlobOid: string | null; + }; } export type ManagedWorkspaceReadOnlyResult = @@ -75,6 +79,7 @@ export type ManagedWorkspaceMutationResult = readonly path: string; readonly bytes: number; readonly diff?: string; + readonly resultBlobOid: string; } | { readonly kind: 'edit'; @@ -85,6 +90,7 @@ export type ManagedWorkspaceMutationResult = readonly startLine: number; readonly endLine: number; readonly diff?: string; + readonly resultBlobOid: string; }; export type ManagedWorkspaceFilesystemResult = @@ -205,9 +211,17 @@ export function createManagedWorkspaceWorkerBridgeInternal( createWorkspaceWritePermissionProfile(), 0, ), + mutationEvidence: { + objectFormat: state.objectFormat, + baseBlobOid: state.baseBlobOid, + }, ...(abortSignal ? { abortSignal } : {}), }); - if (!isMutationResult(result) || result.kind !== operation.kind) { + if ( + !isMutationResult(result) || + result.kind !== operation.kind || + !blobOidMatchesObjectFormat(result.resultBlobOid, state.objectFormat) + ) { throw new ManagedWorkspaceWorkerBridgeError( 'managed_workspace_operation_denied', 'Managed workspace mutation worker returned a mismatched result', @@ -247,3 +261,10 @@ function isMutationResult( ): input is ManagedWorkspaceMutationResult { return input.kind === 'write' || input.kind === 'edit'; } + +function blobOidMatchesObjectFormat(oid: unknown, objectFormat: 'sha1' | 'sha256'): oid is string { + return ( + typeof oid === 'string' && + (objectFormat === 'sha1' ? /^[0-9a-f]{40}$/u : /^[0-9a-f]{64}$/u).test(oid) + ); +} diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 440ba77e1a..a5c594575c 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -1615,6 +1615,16 @@ export class SqliteRuntimeStore if (!Array.isArray(expectedPaths) || expectedPaths.some((path) => typeof path !== 'string')) { throw new Error('Managed mutation reservation has invalid expected paths'); } + const dispatchEvent = this.readRequiredRuntimeEvent(reservation.dispatch_event_id); + const mutation = dispatchEvent.actions?.toolDispatch?.managedMutation; + if ( + !mutation || + mutation.protocol !== 'managed_mutation_v1' || + mutation.workspaceInstanceId !== reservation.workspace_instance_id || + mutation.baseTreeOid !== reservation.base_tree_oid + ) { + throw new Error('Managed mutation reservation has invalid immutable dispatch evidence'); + } return { workspaceInstanceId: reservation.workspace_instance_id, repositoryId: reservation.repository_id, @@ -1627,6 +1637,7 @@ export class SqliteRuntimeStore baseHeadRevision: reservation.base_head_revision, baseCommitOid: reservation.base_commit_oid, baseTreeOid: reservation.base_tree_oid, + baseBlobOid: mutation.baseBlobOid, expectedPaths, executionProfileDigest: reservation.execution_profile_digest, reservedAt: reservation.reserved_at, diff --git a/packages/storage/src/workspace-version-authority-internal.ts b/packages/storage/src/workspace-version-authority-internal.ts index fa26fbd324..4a6ba24884 100644 --- a/packages/storage/src/workspace-version-authority-internal.ts +++ b/packages/storage/src/workspace-version-authority-internal.ts @@ -61,6 +61,7 @@ export interface ManagedMutationReservationRecordV1 { readonly baseHeadRevision: number; readonly baseCommitOid: string; readonly baseTreeOid: string; + readonly baseBlobOid: string | null; readonly expectedPaths: readonly string[]; readonly executionProfileDigest: string; readonly reservedAt: number; diff --git a/scripts/recovery-test-inventory.mjs b/scripts/recovery-test-inventory.mjs index 353704d045..512522207a 100644 --- a/scripts/recovery-test-inventory.mjs +++ b/scripts/recovery-test-inventory.mjs @@ -15,10 +15,15 @@ export const RECOVERY_TEST_INVENTORIES = Object.freeze({ 'packages/storage/src/git-workspace-service.ts', 'packages/storage/src/managed-mutation-candidate-authority-internal.ts', 'packages/storage/src/managed-workspace-owner.ts', + 'packages/storage/src/managed-workspace-execution-authority-internal.ts', + 'packages/storage/src/managed-workspace-worker-bridge-internal.ts', 'packages/storage/src/sqlite-runtime-schema.ts', 'packages/storage/src/sqlite-runtime-store.ts', 'packages/storage/src/workspace-version-authority-internal.ts', 'packages/runtime/src/tool-runtime.ts', + 'packages/runtime/src/filesystem-worker/client.ts', + 'packages/runtime/src/filesystem-worker/operations.ts', + 'packages/runtime/src/filesystem-worker/protocol.ts', 'packages/runtime-host/src/server/execution-composition.ts', 'packages/runtime-host/src/server/managed-workspace-mutation-session.ts', 'packages/storage/src/__tests__/managed-workspace-baseline.test.ts', @@ -29,6 +34,7 @@ export const RECOVERY_TEST_INVENTORIES = Object.freeze({ 'packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts', 'packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts', 'packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts', + 'packages/runtime/src/__tests__/filesystem-worker.test.ts', 'packages/runtime-host/src/__tests__/managed-workspace-write-edit-crash.test.ts', 'packages/runtime-host/src/__tests__/fixtures/managed-workspace-write-edit-host-crash.ts', ]), From 942a33bf70bf6380d27f5b9e02b2792a4587f237 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Wed, 19 Aug 2026 09:30:36 +0800 Subject: [PATCH 6/9] fix(storage): detach managed mutations from worktree writes --- ...e-git-mutation-candidate-owner-v1.zh-CN.md | 34 +- ...ed-workspace-m2-extraction-ledger.zh-CN.md | 11 +- ...orkspace-write-edit-production-v1.zh-CN.md | 46 +- .../src/server/execution-composition.ts | 17 +- .../filesystem-worker-client.test.ts | 36 + .../src/__tests__/filesystem-worker.test.ts | 56 +- .../runtime/src/filesystem-worker/client.ts | 46 +- .../src/filesystem-worker/operations.ts | 72 +- .../runtime/src/filesystem-worker/protocol.ts | 22 +- .../git-workspace-service-crash-child.ts | 15 +- ...naged-mutation-candidate-authority.test.ts | 176 ++++- .../__tests__/managed-workspace-owner.test.ts | 20 +- packages/storage/src/git-workspace-service.ts | 634 +++++++++++++++--- ...d-mutation-candidate-authority-internal.ts | 16 +- ...-workspace-execution-authority-internal.ts | 1 + .../storage/src/managed-workspace-owner.ts | 12 +- ...anaged-workspace-worker-bridge-internal.ts | 34 +- scripts/recovery-test-inventory.mjs | 6 +- 18 files changed, 1002 insertions(+), 252 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md index 43caba2035..6f4641e616 100644 --- a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md @@ -1,15 +1,15 @@ # Managed Workspace Git Mutation Candidate Owner v1 -- 状态:M2.2 核心实现完成;已在最新 main 上重建并仅叠加 M2.1,保持 Draft 等待 M2.3/M2.4 消费者 -- 更新日期:2026-08-17 -- 主要不变量:只有 Maka 的 Git artifact owner 能把 owned worktree 中一次声明路径的变化发布成 operation-bound candidate +- 状态:M2.2 核心 owner,M2.4 将其输入收紧为 Runtime-owned exact result content;API-only Draft +- 更新日期:2026-08-19 +- 主要不变量:只有 Maka 的 Git artifact owner 能把 Runtime-owned transform result 写入 private index 并发布成 operation-bound candidate;worktree 不是 candidate 输入 - owner:既有 `GitWorkspaceService`;不新建第二个 repository/ref writer - canonical artifact:candidate Git commit/ref + strict durable receipt - 不做:不写 T2、不推进 SQLite workspace head、不更新 worktree `HEAD`、不接 Desktop/CLI ## 1. 为什么是 candidate,而不是直接提交 head -工具执行后的文件状态还不能直接成为 canonical workspace version。M2.2 先将它冻结成候选: +工具的 result content 还不能直接成为 canonical workspace version。candidate owner 先将它冻结成候选: ```text accepted base commit @@ -25,7 +25,7 @@ operation-bound candidate ref durable candidate receipt ``` -candidate 只证明“Git owner 看到了什么”。只有后续 M2.4 将它与 T1 identity、工具语义和 M2.1 SQLite +candidate 只证明“Git owner 对哪一份 exact result content 重算并写入了什么”。只有 M2.4 将它与 T1 identity、工具语义和 M2.1 SQLite bundle 一起验证并提交后,它才成为 accepted workspace version。 ## 2. 权限边界 @@ -39,8 +39,8 @@ config 和 storage-root writer lock。M2.2 通过 storage-internal `WeakMap` cap - binding、repository、epoch artifact 和 Git runtime identity 一致; - worktree 是非 symlink 目录,common-dir 指向 Maka repository,worktree lock 仍存在; - worktree `HEAD`、managed head ref、base commit/tree 同时匹配; -- status 只有声明的路径,没有 ignored、rename/copy 或额外变化; -- 首版单文件 mutation 的 candidate tree 中,目标 blob 必须精确等于 worker result blob(删除为 absent); +- worktree status 必须 clean;projection drift 只能 fail closed 或在 accept 时整体保留,绝不进入 candidate; +- 首版单文件 mutation 的 result content 必须与 worker result blob 匹配,owner 再用 verified Git `hash-object` 重算 OID并写入 private index(删除为 absent); - candidate 全树只有普通 blob mode `100644/100755`,没有 symlink、submodule、special mode、属性文件或大小写冲突; - candidate commit 只有一个 parent,且 commit identity/message 使用固定协议; - receipt 的 ref、commit、tree、parent、递归文件级 delta digest 和路径集合可从 Git object database 重算; @@ -57,11 +57,11 @@ sequenceDiagram participant R as Maka Git Repository participant D as Durable Receipt Directory - C->>G: capture(binding, operation, baseHead, expectedPath, expectedBlob, profile) + C->>G: capture(binding, operation, baseHead, expectedPath, expectedBlob/content, profile) G->>G: acquire storage-root writer lock G->>G: verify binding, worktree owner, exact base and status G->>R: read-tree(base) into private temporary index - G->>R: add declared paths and write-tree + G->>R: hash exact result content; update-index declared path; write-tree G->>R: commit-tree(tree, parent=base) G->>R: CAS create operation-bound candidate ref G->>R: recompute tree modes and delta @@ -73,7 +73,7 @@ sequenceDiagram 因此合同依靠可重放状态,而不是宣称不存在中间状态: - object 已写但 ref 未写:unreachable object,Git GC 可回收; -- ref 已写但 receipt 未写:相同 operation/base/worktree 重试生成相同 commit 并补 receipt; +- ref 已写但 receipt 未写:相同 operation/base/result content 重试生成相同 commit 并补 receipt; - receipt 已写:重启后严格重验 Git artifact 和 delta,exact retry 返回同一 receipt; - ref 与当前 candidate 不一致:fail closed,不覆盖。 @@ -96,7 +96,7 @@ tombstone 在删除 ref 前落盘,所以崩溃后不会把“外部删 ref” | 情况 | 结果 | 后续 | |---|---|---| | base/head/worktree owner 漂移 | `managed_workspace_drifted` 或 identity conflict | park;不发布 ref | -| undeclared、ignored、dependency/control path | `managed_mutation_candidate_rejected` | 不发布 ref | +| worktree projection drift、undeclared/ignored residue | `managed_mutation_candidate_rejected` | 不读取其内容,不发布 ref | | candidate 目标 blob 与 worker result blob 不同 | `managed_mutation_candidate_rejected` | 不发布 ref;park/quarantine | | symlink/submodule/special mode/attributes/case collision | reject | 不发布 receipt;已生成 object 可 GC | | 同 operation 已有不同 ref/receipt | identity conflict | fail closed | @@ -112,23 +112,23 @@ tombstone 在删除 ref 前落盘,所以崩溃后不会把“外部删 ref” | private temporary index | 支持 | 支持 | 支持 | | symlink candidate 拒绝 | Git tree mode 验证;实测 | Git tree mode 验证;实测 | 同一 tree-mode 验证;创建 symlink fixture 可能跳过 | | operation path identity | case-sensitive | case-sensitive | filesystem 路径比较 case-insensitive;Git path 仍严格 | -| process-crash convergence | 承诺;Linux storage stress CI | 实现预期支持,但 v1 未持续验证、不作承诺 | 承诺;Windows recovery CI | +| process-crash convergence | 承诺;Linux recovery inventory | 承诺;macOS recovery inventory | 承诺;Windows recovery inventory | | power-loss ordering | v1 不承诺 | v1 不承诺 | v1 不承诺 | 普通 `fsync`、Git ref 的平台实现和设备缓存不足以构成统一的断电证明,所以 v1 只声明进程崩溃收敛。 进程崩溃承诺由真实 child-process kill/reopen 测试约束:capture 在 ref publication 后被杀,新进程补齐同一 -receipt;discard 在 ref deletion 后被杀,新进程依 tombstone 幂等完成清理。统一的 -`scripts/recovery-test-inventory.mjs` 拥有 recovery suite 和 Linux/Windows 期望数量,Windows workflow 直接 -消费该 inventory;Linux storage stress 全量测试包含相同用例。仓库当前没有 macOS runner,因此 v1 不把 macOS -process-crash convergence 写成已证明能力。嵌套目录的新增、修改与删除均以递归 `diff-tree -r` 的文件路径作为 +receipt;discard 在 ref deletion 后被杀,新进程依 tombstone 幂等完成清理;projection rotation 在保存旧目录及 +发布新目录两个点被杀,新进程只收敛投影且保留外部内容。统一的 +`scripts/recovery-test-inventory.mjs` 拥有 recovery suite 和三平台期望数量,Linux/macOS/Windows workflow 消费 +同一 inventory。嵌套目录的新增、修改与删除均以递归 `diff-tree -r` 的文件路径作为 receipt 证据,不能退化成顶层目录名。 ## 7. 留给 M2.3a/M2.3b/M2.4 的边界 M2.2 单独不证明调用者提供的 expected blob 一定由某一次 Write/Edit 造成。M2.3a 必须把 T1 identity 与 durable exclusive reservation 原子绑定并把 exact changed paths 带入 accepted truth;M2.3b 在 T1 前冻结 owner-bound execution admission; -M2.4 必须让真实 worker 验证 T1 base blob、返回 result blob,再由 candidate owner 核对 immutable tree,调用 M2.1 原子 bundle,并在真实 Host kill/reopen +M2.4 让真实 worker 从 Git immutable base content 执行生产 transform、返回 result content/blob,再由 candidate owner 重算并核对 immutable tree,调用 M2.1 原子 bundle,并在真实 Host kill/reopen 测试中证明唯一 accepted successor。 因此本切片保持 Draft;它没有生产 consumer,也不提升当前 Desktop 的 resume 能力。 diff --git a/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md b/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md index 9c6ad60949..79b23db782 100644 --- a/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-m2-extraction-ledger.zh-CN.md @@ -72,8 +72,9 @@ Runtime settlement hook 属于 M2.3b,其余三项由 M2.4 的真实 owner/cons | `docs/architecture/runtime-managed-workspace-mutation-execution-admission-v1.zh-CN.md` | M2.3b | Runtime owner、失败状态、平台证据和 M2.4 seam | M2.3b 不迁入 `ManagedWorkspaceOwner` mutation lease/profile、built-in Write/Edit 标记、filesystem worker mutation、 -Git candidate capture/discard、successor/error bundle composition 或 Desktop/CLI wiring;这些共同构成 M2.4 的首个 -生产消费者。只读 worker 的存在不能签发 mutation profile,caller callback 也不能自证执行能力。 +Git candidate capture/discard、successor/error bundle composition 或 Desktop/CLI wiring;前四项共同构成 M2.4 的 +Runtime Host API composition,Desktop/CLI 产品入口仍未接入。只读 worker 的存在不能签发 mutation profile,caller +callback 也不能自证执行能力。 ## M2.4 增量归属 @@ -81,12 +82,14 @@ Git candidate capture/discard、successor/error bundle composition 或 Desktop/C |---|---| | `packages/core/src/session.ts` | 显式 `managed-coding-v1` profile;attached profile 不静默升级 | | `packages/runtime/src/tool-runtime.ts` | Runtime-owned result snapshot;managed terminal 只采用 exact durable proof | +| `packages/runtime/src/filesystem-worker/{protocol,client,operations}.ts` | worker protocol v7;immutable Git content transform,不授予 worktree read/write authority | | `packages/storage/src/managed-workspace-owner.ts` | 同一 execution handle 绑定 head/path/worker profile/candidate/settlement | +| `packages/storage/src/managed-workspace-worker-bridge-internal.ts` | 把 base content 交给受限 worker并收回 exact result content/blob,不暴露 host-local write seam | | `packages/storage/src/sqlite-runtime-store.ts` | successor 或 no-effect terminal 的专用原子 writer;generic T2 继续被拒绝 | -| `packages/storage/src/git-workspace-service.ts` | candidate capture/accept 与成功 no-op 的明确区分 | +| `packages/storage/src/git-workspace-service.ts` | result content 直接进入 private index;candidate accept 用 crash-convergent projection rotation,禁止 `reset --hard` | | `packages/runtime-host/src/server/managed-workspace-mutation-session.ts` | production Host 将 Read/Glob/Grep/Write/Edit 路由到同一 owner-bound workspace | | `packages/runtime-host/src/server/execution-composition.ts` | 仅显式 managed profile 取得 managed admission + mutation worker | -| `scripts/recovery-test-inventory.mjs` | successor SQLite commit 后真实 process kill/reopen 的三平台证据 | +| `scripts/recovery-test-inventory.mjs` | successor SQLite commit 与 projection rotation 中间点真实 process kill/reopen 的三平台证据 | | `runtime-managed-workspace-write-edit-production-v1.zh-CN.md` | M2.4 owner、原子边界、失败状态和平台矩阵 | M2.4 不绑定 continuation cursor,也不在 attached checkout 自动 redo。前者属于 M3,restore/rebaseline/publish diff --git a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md index 586085b3cb..02773b7731 100644 --- a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md @@ -13,12 +13,12 @@ explicit managed-coding-v1 profile -> Host 打开同一个 owner-bound managed workspace execution handle -> owner 从真实 filesystem worker permission profile + protocol 计算 profile digest - -> Git owner 从 accepted base tree 读取目标路径的 exact base blob + -> Git owner 从 accepted base tree 读取目标路径的 exact base blob/content -> T1 原子持久化 call + dispatch + exact base tree/blob/path/profile + exclusive reservation - -> worker 在同一 transform 进程内先验证 base blob,再返回 exact result blob - -> Git owner capture candidate,并验证 immutable candidate tree 的目标 blob 等于 worker result blob + -> worker 只读 immutable base content,在不写 worktree 的情况下运行生产 transform + -> Runtime 得到 exact result content/blob;Git owner 重新 hash 并写入 private index/candidate -> SQLite 原子提交 exact T2 + successor fact + canonical head,并释放 reservation - -> Git candidate 幂等 accept,将 worktree 投影到 SQLite accepted head + -> Git candidate 幂等 accept,通过目录轮换把 worktree 投影到 SQLite accepted head ``` caller 不能提供 cwd、base head、candidate、execution profile digest 或 terminal result。普通 @@ -39,17 +39,29 @@ owner 和 successor 协议,因此不会暴露给 managed task;不能借由 | mutation reservation | SQLite workspace authority | 与 T1 call/dispatch 同一 transaction | | accepted workspace head | immutable RuntimeEvents | exact T2 + successor fact + projection/head CAS + reservation release 同一 transaction | | Git candidate | Git candidate owner | private index、candidate ref CAS、durable receipt | -| worktree 当前内容 | Git projection | 从 SQLite accepted head + durable candidate receipt 幂等重放;不是 accepted truth | +| worktree 当前内容 | Git projection | 从 SQLite accepted head + durable candidate receipt 幂等物化;不是 transform 输入,也不是 accepted truth | SQLite successor 已提交而 Git accept 尚未完成时,系统已经拥有 canonical accepted truth。新进程重新打开 workspace 时必须从 SQLite head 找到 operation-bound candidate,严格重验 commit/tree/path/profile 后再 accept;不得 重新执行 Write/Edit。 changed-path 集合不是内容因果证据。首版 Write/Edit 只允许一个 canonical path,T1 中的 `baseBlobOid` -证明 operation 所面对的 preimage(新建文件为 `null`);worker 的 `resultBlobOid` 证明同一份生产 transform -生成的 exact after-image;candidate receipt 则把该 digest 绑定到 immutable Git tree。外部进程无论在 worker -之前还是 worker 与 capture 之间修改同一路径,都会分别在 preimage 或 candidate blob 验证处 fail closed,不能 -仅因为 changed paths 仍然相同而进入 accepted successor。 +绑定 accepted Git tree 的 preimage(新建文件为 `null`)。Git owner 同时从该 tree 读取 bounded UTF-8 +`baseContent`,但 content 不作为第二份 durable fact;worker 的 operation boundary 只描述目标 identity,实际 sandbox +profile 不授予任何 workspace read/write 权限;worker 只从受限 request 读取 base content 并运行生产 transform, +既不读取也不写入 worktree,并返回 bounded `resultContent/resultBlobOid`。Git owner 对 result content 重新执行 +Git object hash,把 exact blob 写进 private index,再构造 candidate。candidate receipt 最终把该 blob 绑定到 +immutable Git tree。 + +因此 managed worktree 是可丢弃投影,不是 mutation data plane。外部进程在 admission 后修改同一路径时: + +- candidate capture 前发现 projection drift:fail closed,不吸收外部内容; +- candidate capture 后、projection publish 前发生 drift:旧目录整体移入 quarantine,外部内容原样保留; +- 新 projection 发布后再发生 drift:最终验证拒绝,不运行 `reset --hard` 覆盖内容。 + +本协议明确禁止“校验 worktree preimage 后再原地覆盖”的 read-check-write,因为跨进程写入无法由进程内锁 +线性化。当前 bounded Git/worker transport 只支持 UTF-8 text;二进制或超过 transport 上限的目标在 T1 前 +fail closed。 ## 3. 无副作用终态 @@ -91,11 +103,13 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | T1 前 | 无 reservation | 明确失败,可重新 admission | | T1 后取消,worker 明确未产生 effect 且 Git clean | error T2 + terminal fact | 已收敛,head 不变 | | T1 后、worker 前/中,effect 不可证明 | reservation 保留 | park;M2.4 不自动猜测或覆盖 | -| 同路径外部写入导致 base/result blob mismatch | reservation 保留 | park/quarantine;不得吸收到 successor | +| candidate capture 前 projection 出现任何外部 drift | reservation 保留 | park/quarantine;不得吸收到 successor | | worker error 且 Git owner 证明 clean base | error T2 + terminal fact | 已收敛,head 不变 | | success 但 tree 无变化 | success T2 + terminal fact | 已收敛,head 不变 | | candidate capture 后、SQLite commit 前 | T1 + candidate artifact | reservation 保留;不得对外宣称成功 | -| SQLite successor commit 后、Git accept 前 | accepted successor + candidate receipt | reopen 幂等 accept,不重跑工具 | +| SQLite successor commit 后、projection rotation intent 前 | accepted successor + candidate receipt | reopen 幂等 accept,不重跑工具 | +| rotation 已保存旧目录、尚未发布新投影 | durable rotation intent + quarantined previous projection | reopen 发布新投影;旧内容仍可取回 | +| rotation 已发布新投影、尚未推进 Git metadata/ref | durable rotation intent + 两份目录 | reopen 只推进 metadata/ref 并验证;不原地覆盖文件 | | Git accept 后、provider publication 前 | accepted successor | Runtime 采用 exact durable outcome;后续 replay 同值 | | 外部修改或 evidence mismatch | 不推进/不覆盖 | quarantine 或 park | @@ -106,15 +120,21 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | T1/reservation/terminal/successor SQLite 原子性 | 承诺 | 承诺 | 承诺 | | exact Write/Edit path + worker profile binding | 承诺 | 承诺 | 实现并由边界测试证明;当前 recovery runner 未打包 broker | | candidate capture/accept process-crash 收敛 | CI 证明 | CI 证明 | CI 证明 | +| 外部并发写不被 projection rotation 覆盖 | rename 后旧 inode/目录进入 quarantine | rename 后旧 inode/目录进入 quarantine | 可 rename 时保留旧目录;打开句柄阻止 rename 时 fail closed | | 真实 Host/worker 在 successor commit 后 kill、reopen 不重跑 | CI 证明 | CI 证明 | release broker 存在;当前 recovery runner 明确 skip | | power-loss 后硬件永久写入顺序 | 不承诺 | 不承诺 | 不承诺 | -统一 recovery inventory 执行真实 child process kill/reopen。Linux/macOS 的组合测试经过 Runtime Host、ToolRuntime、 +统一 recovery inventory 执行真实 child process kill/reopen,并覆盖旧 projection 已移动及新 projection 已发布两个 +rotation crash 点。Linux/macOS 的组合测试经过 Runtime Host、ToolRuntime、 真实 `FilesystemWorkerClient`、Git owner 与 SQLite authority,并在 reopen 后执行一个真实 `edit_conflict`,证明 worker -reject 能收敛为 no-effect error terminal;Windows runner 仍执行 29 条 SQLite/Git crash 用例,但 +reject 能收敛为 no-effect error terminal;Windows runner 仍执行 30 条 SQLite/Git crash 用例,但 因没有构建发布包内的 Rust sandbox broker,完整 Host/worker 用例以一个显式 skip 记录,不能表述为已由该 lane 证明。 这里的承诺是 process-crash convergence,不把普通 `fsync`、Git ref 或 SQLite WAL 夸大为断电级证明。 +Projection quarantine 是有意的恢复边界:M2.4 在成功 rotation 后保留旧目录,从而不会为了“自动恢复”删除 +并发写入或仍被外部 fd 引用的 inode。它会增加磁盘占用;配额、宽限期、诊断与安全 GC 由 M4 的 restore/rebaseline +生命周期 owner 统一实现。在该 owner 落地前,本 API 维持 Draft,不能把无界保留包装成生产级缓存策略。 + ## 6. 用户可见边界 M2.4 完成的是“显式 managed coding task 中,一次 Write/Edit 的执行与 accepted workspace version 原子闭环”。 diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 499ee48625..02ce5d7ed2 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -3,7 +3,7 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; import { emptyPlanSessionState } from '@maka/core/plan'; import type { PermissionMode } from '@maka/core/permission'; -import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { isDeepResearchSession } from '@maka/core/session'; import { filterModelVisibleTaskLedgerTasks } from '@maka/core/task-ledger'; import { AgentGraphCoordinator } from '@maka/runtime/stream-graph-coordinator'; @@ -1784,13 +1784,17 @@ function adaptManagedWorkspaceFilesystemWorker( return result; case 'write': case 'edit': - if (!result.resultBlobOid) { + if (!result.resultBlobOid || typeof result.resultContent !== 'string') { throw new RuntimeHostWorkspaceExecutionError( 'workspace_operation_denied', - 'Managed mutation worker omitted its exact result blob identity', + 'Managed mutation worker omitted its exact result content or blob identity', ); } - return { ...result, resultBlobOid: result.resultBlobOid }; + return { + ...result, + resultBlobOid: result.resultBlobOid, + resultContent: result.resultContent, + }; default: throw new RuntimeHostWorkspaceExecutionError( 'workspace_operation_denied', @@ -1806,7 +1810,10 @@ function managedMutationWorkerProfileDigest(): `sha256:${string}` { .update( JSON.stringify({ protocol: 'maka_managed_workspace_mutation_worker_profile_v1', - permissionProfile: createWorkspaceWritePermissionProfile(), + requestPermissionProfile: createReadOnlyPermissionProfile(), + effectiveWorkspaceAccess: 'none_after_operation_scope_intersection_v1', + transformInput: 'immutable_git_base_content_v1', + transformOutput: 'exact_result_content_and_blob_oid_v1', workerProtocol: FILESYSTEM_WORKER_PROTOCOL_VERSION, }), 'utf8', diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index ff12ce1c49..29802ec9f7 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -8,6 +8,7 @@ import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; import { + canReadPath, canWritePath, createReadOnlyPermissionProfile, type PermissionProfile, @@ -143,6 +144,41 @@ describe('filesystem worker client permission snapshots', () => { assert.equal(requests.length, 0); }); + test('runs a detached managed transform with read-only filesystem authority', async () => { + const workspace = await temporaryDirectory('maka-worker-client-detached-transform-'); + const target = join(workspace, 'tracked.txt'); + await writeFile(target, 'external projection content\n', 'utf8'); + const { client, requests, transforms } = fakeClient(); + + await client.execute({ + operation: { kind: 'write', path: target, content: 'candidate\n' }, + cwd: workspace, + executionBoundary: createManagedExecutionBoundary(createReadOnlyPermissionProfile(), 0), + mutationEvidence: { + protocol: 'detached_git_transform_v1', + objectFormat: 'sha1', + baseBlobOid: 'f572d396fae9206628714fb2ce00f72e94f2258f', + baseContent: 'tracked\n', + }, + }); + + assert.equal(requests.length, 1); + assert.equal(requests[0]?.expectedTarget.access, 'read'); + assert.deepEqual(requests[0]?.operationBoundary.filesystem?.entries, [ + { path: target, access: 'read', scope: 'exact' }, + ]); + const transform = transforms[0]; + assert.ok(transform); + assert.equal( + canReadPath(transform.command.profile, target, transform.command.pathContext), + false, + ); + assert.equal( + canWritePath(transform.command.profile, target, transform.command.pathContext), + false, + ); + }); + test('preserves an explicit exact deny without an additional-permission planner', async () => { const workspace = await temporaryDirectory('maka-worker-client-deny-'); const target = join(workspace, 'denied.txt'); diff --git a/packages/runtime/src/__tests__/filesystem-worker.test.ts b/packages/runtime/src/__tests__/filesystem-worker.test.ts index f26ff2522d..e781c38297 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -406,7 +406,7 @@ describe('filesystem worker operations', () => { assert.equal(response.result.diff, undefined); }); - test('binds managed Edit to its exact base blob and returns the exact result blob', async () => { + test('transforms managed Edit from immutable Git content without writing the worktree', async () => { const root = await temporaryDirectory('maka-worker-managed-edit-'); const target = join(root, 'tracked.txt'); await writeFile(target, 'tracked\nEXTERNAL\n', 'utf8'); @@ -420,13 +420,20 @@ describe('filesystem worker operations', () => { oldString: 'tracked', newString: 'updated', }, - { enforcementPath: target, access: 'write', scope: 'exact', targetType: 'file' }, + { enforcementPath: target, access: 'read', scope: 'exact', targetType: 'file' }, target, - { objectFormat: 'sha1', baseBlobOid: gitBlobOid('tracked\n') }, + { + protocol: 'detached_git_transform_v1', + objectFormat: 'sha1', + baseBlobOid: gitBlobOid('tracked\n'), + baseContent: 'tracked\n', + }, ), ); - assert.equal(drifted.ok, false); - if (!drifted.ok) assert.equal(drifted.error.code, 'path_changed'); + assert.equal(drifted.ok, true); + if (!drifted.ok || drifted.result.kind !== 'edit') return; + assert.equal(drifted.result.resultBlobOid, gitBlobOid('updated\n')); + assert.equal(drifted.result.resultContent, 'updated\n'); assert.equal(await readFile(target, 'utf8'), 'tracked\nEXTERNAL\n'); await writeFile(target, 'tracked\n', 'utf8'); @@ -439,15 +446,48 @@ describe('filesystem worker operations', () => { oldString: 'tracked', newString: 'updated', }, - { enforcementPath: target, access: 'write', scope: 'exact', targetType: 'file' }, + { enforcementPath: target, access: 'read', scope: 'exact', targetType: 'file' }, target, - { objectFormat: 'sha1', baseBlobOid: gitBlobOid('tracked\n') }, + { + protocol: 'detached_git_transform_v1', + objectFormat: 'sha1', + baseBlobOid: gitBlobOid('tracked\n'), + baseContent: 'tracked\n', + }, ), ); assert.equal(accepted.ok, true); if (!accepted.ok || accepted.result.kind !== 'edit') return; assert.equal(accepted.result.resultBlobOid, gitBlobOid('updated\n')); - assert.equal(await readFile(target, 'utf8'), 'updated\n'); + assert.equal(accepted.result.resultContent, 'updated\n'); + assert.equal(await readFile(target, 'utf8'), 'tracked\n'); + }); + + test('keeps missing-file Edit semantics in the detached managed transform', async () => { + const root = await temporaryDirectory('maka-worker-managed-missing-edit-'); + const target = join(root, 'missing.txt'); + const response = await executeFilesystemWorkerRequest( + requestFor( + { + kind: 'edit', + cwd: root, + path: target, + oldString: 'old', + newString: 'new', + }, + { enforcementPath: target, access: 'read', scope: 'exact', targetType: 'missing' }, + target, + { + protocol: 'detached_git_transform_v1', + objectFormat: 'sha1', + baseBlobOid: null, + baseContent: null, + }, + ), + ); + + assert.equal(response.ok, false); + if (!response.ok) assert.equal(response.error.code, 'not_found'); }); test('omits the diff when FormatJson leaves the file unchanged', async () => { diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index 0516254238..25951f483b 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -59,8 +59,10 @@ export interface FilesystemWorkerExecuteInput { permissionProfile?: PermissionProfile; abortSignal?: AbortSignal; mutationEvidence?: { + readonly protocol: 'detached_git_transform_v1'; readonly objectFormat: 'sha1' | 'sha256'; readonly baseBlobOid: string | null; + readonly baseContent: string | null; }; } @@ -154,7 +156,8 @@ export class FilesystemWorkerClient { }); if (!parsedOperation.success) throw clientError('invalid_operation', 'validation', requestId); - const access = operationAccess(parsedOperation.data.kind); + const detachedMutation = input.mutationEvidence?.protocol === 'detached_git_transform_v1'; + const access = detachedMutation ? 'read' : operationAccess(parsedOperation.data.kind); const entryMode = operationUsesDirectoryEntry(parsedOperation.data); const target: FilesystemWorkerTarget & { writableAncestor?: string } = await (entryMode ? normalizeDirectoryEntryTarget({ @@ -231,11 +234,7 @@ export class FilesystemWorkerClient { const boundaryTarget = target.writableAncestor ? { path: target.writableAncestor, access: 'write' as const, scope: 'subtree' as const } : { path: target.enforcementPath, access, scope: target.scope }; - const operationBoundary = { - filesystem: { - entries: [boundaryTarget], - }, - } as const; + const operationBoundary = { filesystem: { entries: [boundaryTarget] } } as const; const operation = FilesystemWorkerOperationSchema.parse({ ...parsedOperation.data, path: target.enforcementPath, @@ -260,9 +259,13 @@ export class FilesystemWorkerClient { const launch = await this.input.getLaunchSpec(); if (!launch.ok) throw clientError(launch.reason, 'launch', requestId, launch.message); - const workerProfile = deriveWorkerProfile(effectiveProfile, operationBoundary); + const workerProfile = deriveWorkerProfile( + effectiveProfile, + operationBoundary, + detachedMutation, + ); const pinnedTarget = - platform === 'linux' && !entryMode && target.targetType !== 'missing' + !detachedMutation && platform === 'linux' && !entryMode && target.targetType !== 'missing' ? (() => { try { return pinExistingLinuxProfilePath({ @@ -281,7 +284,13 @@ export class FilesystemWorkerClient { } })() : undefined; - if (platform === 'linux' && !entryMode && target.targetType !== 'missing' && !pinnedTarget) { + if ( + !detachedMutation && + platform === 'linux' && + !entryMode && + target.targetType !== 'missing' && + !pinnedTarget + ) { throw clientError( 'path_changed', 'validation', @@ -487,21 +496,26 @@ function deriveWorkerProfile( ]; }; }, + denyOperationTarget = false, ): PermissionProfile { if (profile.type !== 'managed' || profile.fileSystem.kind !== 'restricted') return profile; - const target = operationBoundary.filesystem.entries[0]; + const target = denyOperationTarget ? undefined : operationBoundary.filesystem.entries[0]; return { ...profile, fileSystem: { ...profile.fileSystem, entries: [ ...profile.fileSystem.entries.filter((entry) => entry.access === 'deny'), - { - kind: 'path', - path: target.path, - access: target.access, - match: target.scope, - }, + ...(target + ? [ + { + kind: 'path' as const, + path: target.path, + access: target.access, + match: target.scope, + }, + ] + : []), ], }, network: { kind: 'restricted' }, diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 50cd377c91..0ffd86db49 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -65,12 +65,14 @@ export async function executeFilesystemWorkerRequest( dependencies: FilesystemWorkerOperationDependencies = {}, ): Promise { try { - await assertTargetUnchanged( - request.operation.cwd, - request.operation.path, - request.expectedTarget, - operationUsesDirectoryEntry(request.operation), - ); + if (request.mutationEvidence?.protocol !== 'detached_git_transform_v1') { + await assertTargetUnchanged( + request.operation.cwd, + request.operation.path, + request.expectedTarget, + operationUsesDirectoryEntry(request.operation), + ); + } return { version: FILESYSTEM_WORKER_PROTOCOL_VERSION, requestId: request.requestId, @@ -132,27 +134,32 @@ export async function executeFilesystemOperation( return { kind: 'read', content: lines.slice(start, end).join('\n') }; } case 'write': { - const path = await resolveWritableAllowed( - operation.cwd, - operation.path, - 'Write', - operationBoundary, - ); + const path = mutationEvidence + ? operation.path + : await resolveWritableAllowed(operation.cwd, operation.path, 'Write', operationBoundary); // Read-before-write: the diff of an overwrite is what tells the reader // what was lost. Only a missing file means the whole content is new — // any other read failure leaves the previous state unknown, and // claiming `--- /dev/null` would report the file as created. let previous: 'new' | 'unknown' | string; let previousBytes: Buffer | undefined; - try { - previousBytes = await fs.readFile(path); - previous = previousBytes.toString('utf8'); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - previous = code === 'ENOENT' || code === 'ENOTDIR' ? 'new' : 'unknown'; + if (mutationEvidence) { + previousBytes = + mutationEvidence.baseContent === null + ? undefined + : Buffer.from(mutationEvidence.baseContent, 'utf8'); + previous = mutationEvidence.baseContent ?? 'new'; + } else { + try { + previousBytes = await fs.readFile(path); + previous = previousBytes.toString('utf8'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + previous = code === 'ENOENT' || code === 'ENOTDIR' ? 'new' : 'unknown'; + } } assertManagedMutationPreimage(previousBytes, mutationEvidence); - await fs.writeFile(path, operation.content, 'utf8'); + if (!mutationEvidence) await fs.writeFile(path, operation.content, 'utf8'); const resultBlobOid = mutationEvidence ? gitBlobOid(Buffer.from(operation.content, 'utf8'), mutationEvidence.objectFormat) : undefined; @@ -167,6 +174,7 @@ export async function executeFilesystemOperation( bytes: Buffer.byteLength(operation.content, 'utf8'), ...(diff !== undefined ? { diff } : {}), ...(resultBlobOid ? { resultBlobOid } : {}), + ...(mutationEvidence ? { resultContent: operation.content } : {}), }; } case 'apply_patch': { @@ -192,14 +200,21 @@ export async function executeFilesystemOperation( return { kind: 'apply_patch', ok: true, path }; } case 'edit': { - const path = await resolveExistingAllowed( - operation.cwd, - operation.path, - 'Edit', - 'write', - operationBoundary, - ); - const contentBytes = await fs.readFile(path); + if (mutationEvidence?.baseContent === null) { + throw operationError('not_found', 'The requested path was not found.'); + } + const path = mutationEvidence + ? operation.path + : await resolveExistingAllowed( + operation.cwd, + operation.path, + 'Edit', + 'write', + operationBoundary, + ); + const contentBytes = mutationEvidence + ? Buffer.from(mutationEvidence.baseContent ?? '', 'utf8') + : await fs.readFile(path); assertManagedMutationPreimage(contentBytes, mutationEvidence); const content = contentBytes.toString('utf8'); let edited: ReturnType; @@ -216,7 +231,7 @@ export async function executeFilesystemOperation( error instanceof Error ? error.message : 'Edit could not be applied.', ); } - await fs.writeFile(path, edited.content, 'utf8'); + if (!mutationEvidence) await fs.writeFile(path, edited.content, 'utf8'); const resultBlobOid = mutationEvidence ? gitBlobOid(Buffer.from(edited.content, 'utf8'), mutationEvidence.objectFormat) : undefined; @@ -231,6 +246,7 @@ export async function executeFilesystemOperation( endLine: edited.endLine, ...(diff !== undefined ? { diff } : {}), ...(resultBlobOid ? { resultBlobOid } : {}), + ...(mutationEvidence ? { resultContent: edited.content } : {}), }; } case 'format_json': { diff --git a/packages/runtime/src/filesystem-worker/protocol.ts b/packages/runtime/src/filesystem-worker/protocol.ts index 55c0a6e190..aeaefc2ec3 100644 --- a/packages/runtime/src/filesystem-worker/protocol.ts +++ b/packages/runtime/src/filesystem-worker/protocol.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import { validateSandboxBoundaryExpansion } from '@maka/core/sandbox-boundary'; -// v6 binds managed Write/Edit to an exact Git preimage and result blob. -export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 6 as const; +// v7 transforms managed Write/Edit from immutable Git content. The canonical +// managed worktree is a projection and is never the mutation input/output. +export const FILESYSTEM_WORKER_PROTOCOL_VERSION = 7 as const; const path = z.string().min(1).max(4096); const cwd = z.string().min(1).max(4096); @@ -11,8 +12,10 @@ const GitBlobOidSchema = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u); const ManagedMutationEvidenceSchema = z .object({ + protocol: z.literal('detached_git_transform_v1'), objectFormat: GitObjectFormatSchema, baseBlobOid: GitBlobOidSchema.nullable(), + baseContent: z.string().nullable(), }) .strict() .superRefine((evidence, context) => { @@ -22,6 +25,9 @@ const ManagedMutationEvidenceSchema = z ) { context.addIssue({ code: 'custom', message: 'Git blob OID does not match object format' }); } + if ((evidence.baseBlobOid === null) !== (evidence.baseContent === null)) { + context.addIssue({ code: 'custom', message: 'Git base blob and content must be paired' }); + } }); const OperationBoundarySchema = z @@ -144,6 +150,16 @@ export const FilesystemWorkerRequestSchema = z message: 'Mutation evidence is permitted only for Write/Edit operations', }); } + if ( + request.mutationEvidence !== undefined && + (request.expectedTarget.access !== 'read' || + request.operationBoundary.filesystem?.entries.some((entry) => entry.access !== 'read')) + ) { + context.addIssue({ + code: 'custom', + message: 'Detached managed transforms must not receive filesystem write authority', + }); + } }); export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ @@ -163,6 +179,7 @@ export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ bytes: z.number().int().nonnegative(), diff: z.string().optional(), resultBlobOid: GitBlobOidSchema.optional(), + resultContent: z.string().optional(), }) .strict(), z.object({ kind: z.literal('apply_patch'), ok: z.literal(true), path: z.string() }).strict(), @@ -177,6 +194,7 @@ export const FilesystemWorkerResultSchema = z.discriminatedUnion('kind', [ endLine: z.number().int().positive(), diff: z.string().optional(), resultBlobOid: GitBlobOidSchema.optional(), + resultContent: z.string().optional(), }) .strict(), z diff --git a/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts b/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts index 60c603d29e..f597cb9460 100644 --- a/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts +++ b/packages/storage/src/__tests__/fixtures/git-workspace-service-crash-child.ts @@ -64,13 +64,13 @@ if (process.env.MAKA_GIT_WORKSPACE_ACTION === 'managed-mutation-owner') { async execute(input) { if (input.operation.kind !== 'write') throw new Error('Expected managed Write'); const path = join(input.cwd, input.operation.path); - writeFileSync(path, input.operation.content, 'utf8'); return { kind: 'write' as const, ok: true as const, path, bytes: Buffer.byteLength(input.operation.content, 'utf8'), resultBlobOid: gitBlobOid(input.operation.content), + resultContent: input.operation.content, }; }, }, @@ -142,7 +142,8 @@ const binding = await service.createManagedWorkspaceFromSource(request); if ( process.env.MAKA_GIT_WORKSPACE_ACTION === 'mutation-capture' || - process.env.MAKA_GIT_WORKSPACE_ACTION === 'mutation-discard' + process.env.MAKA_GIT_WORKSPACE_ACTION === 'mutation-discard' || + process.env.MAKA_GIT_WORKSPACE_ACTION === 'mutation-accept' ) { const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); const candidateRequest = { @@ -160,18 +161,24 @@ if ( }, expectedPaths: ['docs/a.md'], expectedBlobOid: gitBlobOid('candidate from child\n'), + expectedContent: 'candidate from child\n', executionProfileDigest: `sha256:${'e'.repeat(64)}` as const, }; - writeFileSync(join(binding.worktreePath, 'docs', 'a.md'), 'candidate from child\n', 'utf8'); const outputPath = requiredEnv('MAKA_GIT_WORKSPACE_MUTATION_OUTPUT'); if (process.env.MAKA_GIT_WORKSPACE_ACTION === 'mutation-capture') { writeFileSync(outputPath, `${JSON.stringify({ candidateRequest })}\n`, 'utf8'); await requireManagedMutationCandidateAuthorityInternal(service).capture(candidateRequest); - } else { + } else if (process.env.MAKA_GIT_WORKSPACE_ACTION === 'mutation-discard') { const receipt = await requireManagedMutationCandidateAuthorityInternal(service).capture(candidateRequest); writeFileSync(outputPath, `${JSON.stringify({ binding, receipt })}\n`, 'utf8'); await requireManagedMutationCandidateAuthorityInternal(service).discard(receipt); + } else { + const authority = requireManagedMutationCandidateAuthorityInternal(service); + const receipt = await authority.capture(candidateRequest); + writeFileSync(join(binding.worktreePath, 'docs', 'a.md'), 'external before crash\n', 'utf8'); + writeFileSync(outputPath, `${JSON.stringify({ binding, receipt })}\n`, 'utf8'); + await authority.accept(binding, receipt); } throw new Error('Managed mutation crash child missed its failpoint'); } diff --git a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts index 1a0b421438..dc3ad3c3d2 100644 --- a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts +++ b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts @@ -51,7 +51,6 @@ describe('managed mutation candidate authority', () => { const service = await serviceAt(join(root, 'storage')); const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); - await writeFile(join(binding.worktreePath, 'tracked.txt'), 'candidate\n', 'utf8'); const receipt = await requireManagedMutationCandidateAuthorityInternal(service).capture( candidateRequest(binding, baseline), @@ -82,6 +81,105 @@ describe('managed mutation candidate authority', () => { ); }); + test('rejects a non-UTF-8 base blob before issuing transform input', async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source-binary')); + await writeFile(join(sourceRoot, 'tracked.txt'), Buffer.from([0xff, 0xfe, 0x00, 0x61])); + await git(sourceRoot, 'add', 'tracked.txt'); + await git( + sourceRoot, + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'binary baseline', + ); + const service = await serviceAt(join(root, 'storage-binary')); + const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); + const request = candidateRequest(binding, baseline); + + await assert.rejects( + requireManagedMutationCandidateAuthorityInternal(service).readBaseFile( + binding, + request.baseHead, + 'tracked.txt', + ), + (error: unknown) => + error instanceof Error && + 'code' in error && + error.code === 'managed_mutation_candidate_rejected', + ); + }); + + test('rotates the projection and preserves concurrent external content', async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const service = await serviceAt(join(root, 'storage')); + const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(service); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + + await writeFile(join(binding.worktreePath, 'tracked.txt'), 'external concurrent content\n'); + await authority.accept(binding, receipt); + + assert.equal(await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), 'candidate\n'); + const quarantineRoot = join(dirname(binding.worktreePath), 'projection-quarantine'); + const quarantined = await readdir(quarantineRoot); + assert.equal(quarantined.length, 1); + assert.equal( + await readFile(join(quarantineRoot, quarantined[0]!, 'tracked.txt'), 'utf8'), + 'external concurrent content\n', + ); + }); + + test('resumes projection rotation after the previous worktree was preserved', async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const storageRoot = join(root, 'storage'); + let stopped = false; + const interrupted = createGitWorkspaceService({ + storageRoot, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + failpoint(point) { + if (point === 'after_mutation_projection_previous' && !stopped) { + stopped = true; + throw new Error('simulated process stop after preserving previous projection'); + } + }, + }); + const binding = await interrupted.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = + await requireManagedBaselineReceiptAuthorityInternal(interrupted).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(interrupted); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + await writeFile(join(binding.worktreePath, 'tracked.txt'), 'external before crash\n'); + + await assert.rejects( + authority.accept(binding, receipt), + /simulated process stop after preserving previous projection/u, + ); + + const restarted = await serviceAt(storageRoot); + await restarted.openManagedWorkspaceFromBinding(openRequest(sourceRoot)); + assert.equal(await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), 'candidate\n'); + const previous = await readdir(join(dirname(binding.worktreePath), 'projection-quarantine')); + assert.equal( + await readFile( + join(dirname(binding.worktreePath), 'projection-quarantine', previous[0]!, 'tracked.txt'), + 'utf8', + ), + 'external before crash\n', + ); + }); + test('converges the same candidate when the process stops after ref publication', async () => { const root = await temporaryRoot(); const sourceRoot = await createEligibleSource(join(root, 'source')); @@ -109,7 +207,6 @@ describe('managed mutation candidate authority', () => { ['docs/a.md'], 'operation-nested-ref-retry', ); - await writeFile(join(binding.worktreePath, 'docs', 'a.md'), 'candidate\n', 'utf8'); await assert.rejects( requireManagedMutationCandidateAuthorityInternal(interrupted).capture(request), @@ -143,13 +240,6 @@ describe('managed mutation candidate authority', () => { const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); const relativePath = mutation === 'add' ? 'docs/new.md' : 'docs/a.md'; - const targetPath = join(binding.worktreePath, ...relativePath.split('/')); - if (mutation === 'delete') { - await rm(targetPath); - } else { - await writeFile(targetPath, `${mutation}\n`, 'utf8'); - } - const receipt = await requireManagedMutationCandidateAuthorityInternal(service).capture( candidateRequest( binding, @@ -194,7 +284,6 @@ describe('managed mutation candidate authority', () => { const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); const request = candidateRequest(binding, baseline); - await writeFile(join(binding.worktreePath, 'tracked.txt'), 'candidate\n', 'utf8'); await requireManagedMutationCandidateAuthorityInternal(service).capture(request); const receiptRoot = join(dirname(binding.worktreePath), 'mutation-candidates'); @@ -235,7 +324,6 @@ describe('managed mutation candidate authority', () => { const binding = await interrupted.createManagedWorkspaceFromSource(openRequest(sourceRoot)); const baseline = await requireManagedBaselineReceiptAuthorityInternal(interrupted).issue(binding); - await writeFile(join(binding.worktreePath, 'tracked.txt'), 'candidate\n', 'utf8'); const receipt = await requireManagedMutationCandidateAuthorityInternal(interrupted).capture( candidateRequest(binding, baseline), ); @@ -324,6 +412,62 @@ describe('managed mutation candidate authority', () => { } }); + test('converges projection rotation after a real process is killed without overwriting drift', { + skip: !RUN_REAL_PROCESS_CRASH_TESTS, + timeout: 120_000, + }, async () => { + for (const failpoint of [ + 'after_mutation_projection_previous', + 'after_mutation_projection_publish', + ] as const) { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, `source-${failpoint}`)); + const storageRoot = join(root, `storage-${failpoint}`); + const outputPath = join(root, `mutation-output-${failpoint}.json`); + const child = spawnMutationCrashChild({ + action: 'mutation-accept', + failpoint, + sourceRoot, + storageRoot, + outputPath, + }); + try { + await waitForReady(child, 30_000); + child.kill('SIGKILL'); + await waitForExit(child); + const { binding, receipt } = JSON.parse(await readFile(outputPath, 'utf8')) as { + binding: ManagedWorkspaceBinding; + receipt: ManagedMutationCandidateReceiptV1; + }; + + const restarted = await serviceAt(storageRoot); + await restarted.openManagedWorkspaceFromBinding(openRequest(sourceRoot)); + assert.equal( + await readFile(join(binding.worktreePath, 'docs', 'a.md'), 'utf8'), + 'candidate from child\n', + ); + const previous = await readdir( + join(dirname(binding.worktreePath), 'projection-quarantine'), + ); + const preserved = await Promise.all( + previous.map((name) => + readFile( + join(dirname(binding.worktreePath), 'projection-quarantine', name, 'docs', 'a.md'), + 'utf8', + ), + ), + ); + assert.ok(preserved.includes('external before crash\n')); + assert.equal( + await gitBare(binding.repositoryPath, 'rev-parse', '--verify', binding.headRef), + receipt.candidateCommitOid, + ); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } + } + }); + test('rejects undeclared and ignored workspace changes before publishing a ref', async () => { for (const extraPath of ['extra.txt', 'ignored.env']) { const root = await temporaryRoot(); @@ -331,7 +475,6 @@ describe('managed mutation candidate authority', () => { const service = await serviceAt(join(root, `storage-${extraPath}`)); const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); - await writeFile(join(binding.worktreePath, 'tracked.txt'), 'candidate\n', 'utf8'); await writeFile(join(binding.worktreePath, extraPath), 'not declared\n', 'utf8'); await assert.rejects( @@ -380,6 +523,7 @@ function candidateRequest( }, expectedPaths, expectedBlobOid: expectedContent === null ? null : gitBlobOid(expectedContent), + expectedContent, executionProfileDigest: `sha256:${'e'.repeat(64)}`, } as const; } @@ -458,8 +602,12 @@ async function sha256File(path: string): Promise<`sha256:${string}`> { } function spawnMutationCrashChild(input: { - action: 'mutation-capture' | 'mutation-discard'; - failpoint: 'after_mutation_candidate_ref' | 'after_mutation_candidate_discard_ref'; + action: 'mutation-capture' | 'mutation-discard' | 'mutation-accept'; + failpoint: + | 'after_mutation_candidate_ref' + | 'after_mutation_candidate_discard_ref' + | 'after_mutation_projection_previous' + | 'after_mutation_projection_publish'; sourceRoot: string; storageRoot: string; outputPath: string; diff --git a/packages/storage/src/__tests__/managed-workspace-owner.test.ts b/packages/storage/src/__tests__/managed-workspace-owner.test.ts index 5ff17ee8b4..5ac5d91879 100644 --- a/packages/storage/src/__tests__/managed-workspace-owner.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-owner.test.ts @@ -318,13 +318,13 @@ test('accepts a worker-owned Write only after capturing its Git candidate', asyn assert.equal(input.operation.kind, 'write'); if (input.operation.kind !== 'write') throw new Error('expected Write'); const target = join(input.cwd, input.operation.path); - await writeFile(target, input.operation.content, 'utf8'); return { kind: 'write' as const, ok: true as const, path: target, bytes: Buffer.byteLength(input.operation.content, 'utf8'), resultBlobOid: gitBlobOid(input.operation.content), + resultContent: input.operation.content, }; }, }, @@ -458,7 +458,7 @@ test('accepts a worker-owned Write only after capturing its Git candidate', asyn } }); -test('rejects an Edit candidate that incorporates same-path external content after T1', async () => { +test('rejects same-path external content without letting the detached Edit overwrite it', async () => { const root = await temporaryRoot(); const storageRoot = join(root, 'storage'); const sourceRoot = await createEligibleSource(join(root, 'source')); @@ -482,9 +482,6 @@ test('rejects an Edit candidate that incorporates same-path external content aft assert.equal(input.operation.kind, 'edit'); if (input.operation.kind !== 'edit') throw new Error('expected Edit'); const target = join(input.cwd, input.operation.path); - const before = await readFile(target, 'utf8'); - const after = before.replace(input.operation.oldString, input.operation.newString); - await writeFile(target, after, 'utf8'); return { kind: 'edit' as const, ok: true as const, @@ -493,9 +490,10 @@ test('rejects an Edit candidate that incorporates same-path external content aft matchedVia: 'exact' as const, startLine: 1, endLine: 1, - // The worker proof names only this operation's exact transform; - // the candidate contains an externally injected suffix and must fail. + // The detached transform is derived from Git base content and must + // not overwrite the externally modified projection. resultBlobOid: gitBlobOid('updated\n'), + resultContent: 'updated\n', }; }, }, @@ -538,7 +536,11 @@ test('rejects an Edit candidate that incorporates same-path external content aft durableOutcome: managedMutationOutcome(operationId, toolCallId, content, false, 'Edit'), }; }), - /worker result blob/u, + /external changes/u, + ); + assert.equal( + await readFile(join(binding.worktreePath, args.path), 'utf8'), + 'tracked\nEXTERNAL\n', ); assert.equal( ( @@ -580,13 +582,13 @@ test('atomically settles post-T1 failure and successful no-effect Writes without assert.equal(input.operation.kind, 'write'); if (input.operation.kind !== 'write') throw new Error('expected Write'); const target = join(input.cwd, input.operation.path); - await writeFile(target, input.operation.content, 'utf8'); return { kind: 'write' as const, ok: true as const, path: target, bytes: Buffer.byteLength(input.operation.content, 'utf8'), resultBlobOid: gitBlobOid(input.operation.content), + resultContent: input.operation.content, }; }, }, diff --git a/packages/storage/src/git-workspace-service.ts b/packages/storage/src/git-workspace-service.ts index 6fbec27b2c..3fdcf37b64 100644 --- a/packages/storage/src/git-workspace-service.ts +++ b/packages/storage/src/git-workspace-service.ts @@ -6,12 +6,14 @@ import { mkdir, open as openFile, readFile, + readdir, realpath, rename, rm, stat, + writeFile, } from 'node:fs/promises'; -import { dirname, isAbsolute, join, normalize, posix, relative, resolve } from 'node:path'; +import { dirname, isAbsolute, join, normalize, posix, relative, resolve, sep } from 'node:path'; import { isDeepStrictEqual, promisify } from 'node:util'; import { isCanonicalManagedMutationPathV1 } from '@maka/core/runtime-event'; import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; @@ -21,6 +23,7 @@ import { registerManagedBaselineReceiptAuthorityInternal } from './managed-basel import { registerManagedMutationCandidateAuthorityInternal, type ManagedMutationCandidateReceiptV1, + type ManagedMutationCandidateIdentityRequest, type ManagedMutationCandidateRequest, } from './managed-mutation-candidate-authority-internal.js'; @@ -140,7 +143,8 @@ const MUTATION_CANDIDATE_POLICY_V1 = { symlinks: 'reject', submodules: 'reject', renames: 'reject', - content: 'exact_worker_result_blob', + content: 'runtime_result_content_rehashed_into_private_git_index', + worktreeInput: 'forbidden_projection_only', commitParents: 'exactly_one', } as const; const MUTATION_CANDIDATE_POLICY_HASH_V1 = hashCanonicalJson(MUTATION_CANDIDATE_POLICY_V1); @@ -228,7 +232,10 @@ export type GitWorkspaceServiceFailpoint = | 'after_quarantine_pruned' | 'after_baseline_receipt' | 'after_mutation_candidate_ref' - | 'after_mutation_candidate_discard_ref'; + | 'after_mutation_candidate_discard_ref' + | 'after_mutation_projection_intent' + | 'after_mutation_projection_previous' + | 'after_mutation_projection_publish'; export interface ManagedWorkspaceIdentity { /** @@ -401,6 +408,7 @@ interface WorkspaceLayout { readonly bindingPath: string; readonly baselineReceiptPath: string; readonly mutationCandidateRoot: string; + readonly projectionQuarantineRoot: string; readonly worktreePath: string; readonly quarantineRoot: string; readonly quarantineIntentRoot: string; @@ -413,6 +421,12 @@ interface ManagedMutationCandidateDiscardIntentV1 { readonly receipt: ManagedMutationCandidateReceiptV1; } +interface ManagedMutationProjectionIntentV1 { + readonly schemaVersion: 1; + readonly protocol: 'maka_managed_mutation_projection_v1'; + readonly receipt: ManagedMutationCandidateReceiptV1; +} + class GitWorkspaceServiceImpl implements GitWorkspaceService { private readonly runtime: VerifiedGitRuntime; @@ -431,8 +445,8 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { verify: (receipt) => this.#verifyManagedWorkspaceBaselineReceipt(receipt), }); registerManagedMutationCandidateAuthorityInternal(this, { - readBaseBlob: (binding, baseHead, path) => - this.#readManagedMutationBaseBlob(binding, baseHead, path), + readBaseFile: (binding, baseHead, path) => + this.#readManagedMutationBaseFile(binding, baseHead, path), capture: (request) => this.#captureManagedMutationCandidate(request), require: (binding, operationId) => this.#requireManagedMutationCandidate(binding, operationId), @@ -470,6 +484,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { const existingBinding = await readBinding(layout.bindingPath); if (existingBinding) { assertBindingMatches(existingBinding, input, layout, runtime.digest); + await this.resumePendingMutationProjection(existingBinding, layout); return this.adoptStoredBinding(input, existingBinding, layout); } @@ -574,6 +589,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } assertBindingIdentity(binding, input, layout, runtime.digest); + await this.resumePendingMutationProjection(binding, layout); return this.adoptStoredBinding(input, binding, layout); }); } @@ -784,19 +800,50 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { layout.homePath, indexEnv, ); - await this.runtime.run( - [ - '--literal-pathspecs', - '-C', - binding.worktreePath, - 'add', - '-A', - '--', - ...mutation.changedPaths, - ], - layout.homePath, - indexEnv, - ); + const candidatePath = request.expectedPaths[0]!; + if (request.expectedContent === null) { + await this.runtime.runWithInput( + ['--git-dir', binding.repositoryPath, 'update-index', '--index-info'], + `0 ${'0'.repeat(binding.objectFormat === 'sha1' ? 40 : 64)}\t${candidatePath}\n`, + layout.homePath, + indexEnv, + ); + } else { + const oid = ( + await this.runtime.runWithInput( + ['--git-dir', binding.repositoryPath, 'hash-object', '-w', '--stdin'], + request.expectedContent, + layout.homePath, + ) + ).trim(); + if (oid !== request.expectedBlobOid) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed mutation result content does not match its exact blob identity', + ); + } + const mode = await this.readManagedMutationPathMode( + binding, + baseHead.treeOid, + candidatePath, + layout, + ); + await this.runtime.run( + [ + '--literal-pathspecs', + '--git-dir', + binding.repositoryPath, + 'update-index', + '--add', + '--cacheinfo', + mode ?? '100644', + oid, + candidatePath, + ], + layout.homePath, + indexEnv, + ); + } const candidateTreeOid = ( await this.runtime.run( ['--git-dir', binding.repositoryPath, 'write-tree'], @@ -893,11 +940,11 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { }); } - async #readManagedMutationBaseBlob( + async #readManagedMutationBaseFile( binding: ManagedWorkspaceBinding, baseHead: WorkspaceHeadRecordV1, path: string, - ): Promise { + ): Promise<{ readonly blobOid: string; readonly content: string } | null> { const canonicalPath = assertManagedMutationPath(path); const runtime = await this.runtime.verify(); return await withArtifactWriterLock(this.input.storageRoot, async (canonicalStorageRoot) => { @@ -934,7 +981,19 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { 'Managed mutation base path is not one regular Git blob', ); } - return entries[0]!.oid; + const blobOid = entries[0]!.oid; + const bytes = await this.runtime.runBuffer( + ['--git-dir', binding.repositoryPath, 'cat-file', 'blob', blobOid], + layout.homePath, + ); + const content = bytes.toString('utf8'); + if (gitBlobOid(content, binding.objectFormat) !== blobOid) { + throw new GitWorkspaceServiceError( + 'managed_mutation_candidate_rejected', + 'Managed mutation base blob is not bounded canonical UTF-8 text', + ); + } + return Object.freeze({ blobOid, content }); }); } @@ -1051,53 +1110,195 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { baselineReceipt.policyHash, ); await this.assertMutationCandidateArtifact(receipt, layout); + if (await isNonSymlinkDirectory(binding.worktreePath)) { + const [currentHead, currentHeadRef, currentStatus] = await Promise.all([ + this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD'], layout.homePath), + this.runtime.run( + ['--git-dir', binding.repositoryPath, 'rev-parse', binding.headRef], + layout.homePath, + ), + this.runtime.run( + [ + '-C', + binding.worktreePath, + 'status', + '--porcelain=v1', + '--untracked-files=all', + '--ignored=matching', + ], + layout.homePath, + ), + ]); + if ( + currentHead.trim() === receipt.candidateCommitOid && + currentHeadRef.trim() === receipt.candidateCommitOid + ) { + if (currentStatus.trim() === '') return; + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + 'Accepted managed projection contains later external changes', + ); + } + if ( + currentHead.trim() !== receipt.baseHead.commitOid || + currentHeadRef.trim() !== receipt.baseHead.commitOid + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + 'Managed projection no longer matches the candidate rotation base', + ); + } + } + await this.prepareMutationProjection(binding, receipt, identity.digest, layout); + await this.convergeMutationProjection(binding, receipt, identity.digest, layout); + }); + } - const [head, tree, headRef, status] = await Promise.all([ - this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD'], layout.homePath), - this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD^{tree}'], layout.homePath), - this.runtime.run( - ['--git-dir', binding.repositoryPath, 'rev-parse', binding.headRef], + private async prepareMutationProjection( + binding: ManagedWorkspaceBinding, + receipt: ManagedMutationCandidateReceiptV1, + digest: string, + layout: WorkspaceLayout, + ): Promise { + await ensureOwnedDirectory(layout.mutationCandidateRoot, layout.instanceRoot); + await ensureOwnedDirectory(layout.projectionQuarantineRoot, layout.instanceRoot); + const paths = mutationProjectionPaths(layout, digest); + const existing = await readMutationProjectionIntent(paths.intentPath); + if (existing) { + assertMutationProjectionIntent(existing, receipt); + return; + } + await rm(paths.stagingPath, { recursive: true, force: true }); + await rm(paths.indexPath, { force: true }); + await rm(`${paths.indexPath}.lock`, { force: true }); + await mkdir(paths.stagingPath); + const indexEnv = { GIT_INDEX_FILE: paths.indexPath }; + try { + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'read-tree', receipt.candidateCommitOid], + layout.homePath, + indexEnv, + ); + await this.runtime.run( + [ + '-C', + binding.worktreePath, + '-c', + 'core.bare=false', + '--git-dir', + binding.repositoryPath, + '--work-tree', + binding.worktreePath, + 'checkout-index', + '--all', + '--force', + `--prefix=${paths.stagingPath}${sep}`, + ], + layout.homePath, + indexEnv, + ); + const gitDirectory = ( + await this.runtime.run( + ['-C', binding.worktreePath, 'rev-parse', '--absolute-git-dir'], layout.homePath, - ), - this.runtime.runBuffer( - [ - '--literal-pathspecs', - '-C', - binding.worktreePath, - 'status', - '--porcelain=v1', - '-z', - '--untracked-files=all', - '--ignored=matching', - ], + ) + ).trim(); + const commonDirectory = ( + await this.runtime.run( + ['-C', binding.worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], layout.homePath, - ), - ]); + ) + ).trim(); + if (!samePath(await realpath(commonDirectory), await realpath(binding.repositoryPath))) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed projection Git metadata no longer belongs to its repository', + ); + } + await writeFile(join(paths.stagingPath, '.git'), `gitdir: ${gitDirectory}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + const intent: ManagedMutationProjectionIntentV1 = { + schemaVersion: 1, + protocol: 'maka_managed_mutation_projection_v1', + receipt, + }; + await atomicWriteJson(paths.intentPath, intent); + await this.input.failpoint?.('after_mutation_projection_intent'); + } finally { + await rm(paths.indexPath, { force: true }); + await rm(`${paths.indexPath}.lock`, { force: true }); + } + } + + private async convergeMutationProjection( + binding: ManagedWorkspaceBinding, + receipt: ManagedMutationCandidateReceiptV1, + digest: string, + layout: WorkspaceLayout, + ): Promise { + const paths = mutationProjectionPaths(layout, digest); + const intent = await readMutationProjectionIntent(paths.intentPath); + if (!intent) { + const inspection = await this.inspectBinding(binding, layout); if ( - head.trim() === receipt.candidateCommitOid && - tree.trim() === receipt.candidateTreeOid && - headRef.trim() === receipt.candidateCommitOid && - status.length === 0 + inspection.state === 'ready' && + inspection.commitOid === receipt.candidateCommitOid && + inspection.treeOid === receipt.candidateTreeOid ) { return; } - if ( - head.trim() !== receipt.baseHead.commitOid || - tree.trim() !== receipt.baseHead.treeOid || - headRef.trim() !== receipt.baseHead.commitOid - ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Managed mutation projection intent is unavailable', + ); + } + assertMutationProjectionIntent(intent, receipt); + + const stableExists = await pathEntryExists(binding.worktreePath); + const stagingExists = await pathEntryExists(paths.stagingPath); + const previousExists = await pathEntryExists(paths.previousPath); + if (!previousExists) { + if (!stableExists) { throw new GitWorkspaceServiceError( - 'managed_workspace_drifted', - 'Managed worktree no longer matches the candidate acceptance boundary', + 'managed_workspace_unavailable', + 'Managed mutation projection lost both its current and previous worktree', ); } - const entries = parsePorcelainStatus(status); - if (!sameStringSet(entries.map((entry) => entry.path).sort(), receipt.changedPaths)) { + await rename(binding.worktreePath, paths.previousPath); + await this.input.failpoint?.('after_mutation_projection_previous'); + } + await rm(join(paths.previousPath, '.git'), { force: true }); + + const stableAfterPrevious = await pathEntryExists(binding.worktreePath); + if (!stableAfterPrevious) { + if (!(await pathEntryExists(paths.stagingPath))) { throw new GitWorkspaceServiceError( - 'managed_workspace_drifted', - 'Managed worktree changed after candidate capture', + 'managed_workspace_unavailable', + 'Managed mutation projection staging tree is unavailable', ); } + await rename(paths.stagingPath, binding.worktreePath); + await this.input.failpoint?.('after_mutation_projection_publish'); + } else if (stagingExists || (await pathEntryExists(paths.stagingPath))) { + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + 'A concurrent writer recreated the managed projection during rotation', + ); + } + + await this.runtime.run( + ['-C', binding.worktreePath, 'reset', '--mixed', receipt.candidateCommitOid], + layout.homePath, + ); + const headRef = ( + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'rev-parse', binding.headRef], + layout.homePath, + ) + ).trim(); + if (headRef === receipt.baseHead.commitOid) { await this.updateExistingRefCas( binding.repositoryPath, binding.headRef, @@ -1105,37 +1306,69 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { receipt.baseHead.commitOid, layout.homePath, ); - await this.runtime.run( - ['-C', binding.worktreePath, 'reset', '--hard', receipt.candidateCommitOid], - layout.homePath, + } else if (headRef !== receipt.candidateCommitOid) { + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + 'Managed workspace head ref changed during projection rotation', ); - const [acceptedHead, acceptedTree, acceptedStatus] = await Promise.all([ - this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD'], layout.homePath), - this.runtime.run(['-C', binding.worktreePath, 'rev-parse', 'HEAD^{tree}'], layout.homePath), - this.runtime.run( - [ - '--literal-pathspecs', - '-C', - binding.worktreePath, - 'status', - '--porcelain=v1', - '--untracked-files=all', - '--ignored=matching', - ], - layout.homePath, - ), - ]); - if ( - acceptedHead.trim() !== receipt.candidateCommitOid || - acceptedTree.trim() !== receipt.candidateTreeOid || - acceptedStatus.trim() !== '' - ) { - throw new GitWorkspaceServiceError( - 'managed_workspace_drifted', - 'Managed mutation candidate did not become the clean worktree head', - ); - } - }); + } + const inspection = await this.inspectBinding(binding, layout); + if ( + inspection.state !== 'ready' || + inspection.commitOid !== receipt.candidateCommitOid || + inspection.treeOid !== receipt.candidateTreeOid + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_drifted', + `Managed mutation projection did not converge to the accepted candidate: ${JSON.stringify(inspection)}`, + ); + } + await rm(paths.intentPath, { force: true }); + await rm(paths.stagingPath, { recursive: true, force: true }); + } + + private async resumePendingMutationProjection( + binding: ManagedWorkspaceBinding, + layout: WorkspaceLayout, + ): Promise { + let names: string[]; + try { + names = await readdir(layout.mutationCandidateRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + const intents = names.filter((name) => name.endsWith('.projection.json')); + if (intents.length > 1) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed workspace has multiple pending projection rotations', + ); + } + const name = intents[0]; + if (!name) return; + const digest = name.slice(0, -'.projection.json'.length); + const intent = await readMutationProjectionIntent(join(layout.mutationCandidateRoot, name)); + if (!intent) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed mutation projection intent is invalid', + ); + } + assertMutationProjectionIntent(intent, intent.receipt); + if ( + intent.receipt.repositoryId !== binding.repositoryId || + intent.receipt.workspaceId !== binding.workspaceId || + intent.receipt.workspaceEpochId !== binding.workspaceEpochId || + intent.receipt.workspaceInstanceId !== binding.workspaceInstanceId + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed mutation projection intent belongs to another workspace', + ); + } + await this.assertMutationCandidateArtifact(intent.receipt, layout); + await this.convergeMutationProjection(binding, intent.receipt, digest, layout); } async #requireManagedMutationCandidate( @@ -1252,38 +1485,96 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } const entries = parsePorcelainStatus(status); - if (entries.length === 0) { + if (entries.length !== 0) { throw new GitWorkspaceServiceError( - 'managed_mutation_no_change', - 'Managed mutation did not change the workspace', + 'managed_mutation_candidate_rejected', + 'Managed mutation input projection contains external changes', ); } - if (entries.some((entry) => entry.status === '!!')) { + const baseBlob = await this.readTreeBlobOid( + binding, + baseHead.treeOid, + request.expectedPaths[0]!, + layout, + ); + if (baseBlob === request.expectedBlobOid) { throw new GitWorkspaceServiceError( - 'managed_mutation_candidate_rejected', - 'Managed mutation touched an ignored path', + 'managed_mutation_no_change', + 'Managed mutation did not change the workspace', ); } - if (entries.some((entry) => /[RC]/u.test(entry.status))) { + const changedPaths = request.expectedPaths; + return { + changedPaths, + deletedPaths: request.expectedBlobOid === null ? changedPaths : [], + }; + } + + private async readTreeBlobOid( + binding: ManagedWorkspaceBinding, + treeOid: string, + path: string, + layout: WorkspaceLayout, + ): Promise { + const entries = parseTreeEntries( + await this.runtime.runBuffer( + [ + '--literal-pathspecs', + '--git-dir', + binding.repositoryPath, + 'ls-tree', + '-z', + treeOid, + '--', + path, + ], + layout.homePath, + ), + ).filter((entry) => entry.path === path); + if (entries.length === 0) return null; + if (entries.length !== 1 || entries[0]!.objectType !== 'blob') { throw new GitWorkspaceServiceError( 'managed_mutation_candidate_rejected', - 'Managed mutation candidate does not accept rename or copy status', + 'Managed mutation path is not one regular Git blob', ); } - const changedPaths = entries.map((entry) => entry.path).sort(); - if (!sameStringSet(changedPaths, request.expectedPaths)) { + return entries[0]!.oid; + } + + private async readManagedMutationPathMode( + binding: ManagedWorkspaceBinding, + treeOid: string, + path: string, + layout: WorkspaceLayout, + ): Promise { + const entries = parseTreeEntries( + await this.runtime.runBuffer( + [ + '--literal-pathspecs', + '--git-dir', + binding.repositoryPath, + 'ls-tree', + '-z', + treeOid, + '--', + path, + ], + layout.homePath, + ), + ).filter((entry) => entry.path === path); + if (entries.length === 0) return null; + const entry = entries[0]!; + if ( + entries.length !== 1 || + entry.objectType !== 'blob' || + !['100644', '100755'].includes(entry.mode) + ) { throw new GitWorkspaceServiceError( 'managed_mutation_candidate_rejected', - 'Managed mutation changed an undeclared path', + 'Managed mutation path has an unsupported Git mode', ); } - return { - changedPaths, - deletedPaths: entries - .filter((entry) => entry.status.includes('D')) - .map((entry) => entry.path) - .sort(), - }; + return entry.mode; } private async readMutationDelta( @@ -2290,12 +2581,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { binding.worktreePath, worktreeLockReason(binding), ); - if ( - commitOid !== binding.baselineCommitOid || - treeOid !== binding.baselineTreeOid || - headRef !== binding.baselineCommitOid || - status - ) { + if (commitOid !== headRef || status) { return { state: 'drifted', commitOid, treeOid, status }; } return { state: 'ready', commitOid, treeOid }; @@ -2622,6 +2908,45 @@ class VerifiedGitRuntime { return stdout; } + async runWithInput( + args: readonly string[], + input: string | Buffer, + homePath?: string, + extraEnv?: NodeJS.ProcessEnv, + ): Promise { + const runtime = await this.verify(); + const hooksPath = homePath ? join(homePath, 'empty-hooks') : dirname(runtime.executablePath); + if (homePath) { + await mkdir(homePath, { recursive: true }); + await mkdir(hooksPath, { recursive: true }); + } + const child = spawn(runtime.executablePath, [...fixedGitArguments(hooksPath), ...args], { + cwd: homePath ?? dirname(runtime.executablePath), + env: { + ...extraEnv, + ...isolatedGitEnvironment(this.input, homePath ?? dirname(runtime.executablePath)), + }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + if (!child.stdin) { + child.kill('SIGKILL'); + throw new Error('Git input process did not create stdin'); + } + const stdout = collectBoundedOutput(child.stdout, GIT_MAX_BUFFER_BYTES); + const stderr = collectBoundedStderr(child.stderr); + const timeout = setTimeout(() => child.kill('SIGKILL'), GIT_TIMEOUT_MS); + child.stdin.end(input); + try { + const code = await waitForChildExit(child); + const [output, errorOutput] = await Promise.all([stdout, stderr]); + if (code !== 0) throw new Error(`Git command failed (${String(code)}): ${errorOutput}`); + return output; + } finally { + clearTimeout(timeout); + } + } + async runOptional( args: readonly string[], acceptedMissingExitCode: number, @@ -2772,6 +3097,7 @@ function workspaceLayout( bindingPath: join(instanceRoot, 'binding.json'), baselineReceiptPath: join(instanceRoot, 'baseline-receipt.json'), mutationCandidateRoot: join(instanceRoot, 'mutation-candidates'), + projectionQuarantineRoot: join(instanceRoot, 'projection-quarantine'), worktreePath: join(instanceRoot, 'worktree'), quarantineRoot: join(managedRoot, 'quarantine'), quarantineIntentRoot, @@ -2782,6 +3108,21 @@ function workspaceLayout( }; } +function mutationProjectionPaths(layout: WorkspaceLayout, digest: string) { + if (!/^[a-f0-9]{64}$/u.test(digest)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Invalid managed mutation projection identity', + ); + } + return { + intentPath: join(layout.mutationCandidateRoot, `${digest}.projection.json`), + stagingPath: join(layout.mutationCandidateRoot, `${digest}.projection-next`), + indexPath: join(layout.mutationCandidateRoot, `${digest}.projection.index`), + previousPath: join(layout.projectionQuarantineRoot, digest), + } as const; +} + function assertOpenIdentity(input: ManagedWorkspaceIdentity): void { for (const [name, value] of Object.entries({ repositoryId: input.repositoryId, @@ -2975,6 +3316,42 @@ async function readMutationCandidateDiscardIntent( }; } +async function readMutationProjectionIntent( + path: string, +): Promise { + const value = await readJson(path); + if (value === undefined) return undefined; + if ( + !isRecord(value) || + !hasExactKeys(value, ['schemaVersion', 'protocol', 'receipt']) || + value.schemaVersion !== 1 || + value.protocol !== 'maka_managed_mutation_projection_v1' || + !isMutationCandidateReceipt(value.receipt) + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Invalid managed mutation projection intent', + ); + } + return { + schemaVersion: 1, + protocol: 'maka_managed_mutation_projection_v1', + receipt: value.receipt, + }; +} + +function assertMutationProjectionIntent( + intent: ManagedMutationProjectionIntentV1, + receipt: ManagedMutationCandidateReceiptV1, +): void { + if (!isDeepStrictEqual(intent.receipt, receipt)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed mutation projection intent changed identity', + ); + } +} + async function readRepositoryRecord(path: string): Promise { const value = await readJson(path); if (value === undefined) return undefined; @@ -3204,6 +3581,7 @@ function assertMutationCandidateRequest(request: ManagedMutationCandidateRequest 'baseHead', 'expectedPaths', 'expectedBlobOid', + 'expectedContent', 'executionProfileDigest', ]) ) { @@ -3218,6 +3596,11 @@ function assertMutationCandidateRequest(request: ManagedMutationCandidateRequest !SHA256_PATTERN.test(request.executionProfileDigest) || (request.expectedBlobOid !== null && !oidMatchesObjectFormat(request.expectedBlobOid, request.binding.objectFormat)) || + (request.expectedContent !== null && typeof request.expectedContent !== 'string') || + (request.expectedBlobOid === null) !== (request.expectedContent === null) || + (request.expectedContent !== null && + request.expectedBlobOid !== + gitBlobOid(request.expectedContent, request.binding.objectFormat)) || !isWorkspaceHeadRecord(request.baseHead) || request.expectedPaths.length !== 1 ) { @@ -3359,7 +3742,7 @@ function isMutationCandidateReceipt(value: unknown): value is ManagedMutationCan function assertMutationCandidateReceiptMatches( receipt: ManagedMutationCandidateReceiptV1, - request: ManagedMutationCandidateRequest, + request: ManagedMutationCandidateIdentityRequest, candidateRef: string, workspacePolicyHash: `sha256:${string}`, ): void { @@ -3387,6 +3770,14 @@ function assertMutationCandidateReceiptMatches( } } +function gitBlobOid(content: string, objectFormat: 'sha1' | 'sha256'): string { + const bytes = Buffer.from(content, 'utf8'); + return createHash(objectFormat) + .update(`blob ${bytes.byteLength}\0`, 'utf8') + .update(bytes) + .digest('hex'); +} + function isWorkspaceHeadRecord( value: unknown, ): value is ManagedMutationCandidateRequest['baseHead'] { @@ -3699,6 +4090,7 @@ async function assertOwnedManagedWorkspaceLayout( await assertOwnedDirectoryEntry(layout.instanceRoot, layout.epochRoot, false); await assertOwnedDirectoryEntry(layout.worktreePath, layout.instanceRoot, false); await assertOwnedDirectoryEntry(layout.mutationCandidateRoot, layout.instanceRoot, false); + await assertOwnedDirectoryEntry(layout.projectionQuarantineRoot, layout.instanceRoot, false); } async function assertOwnedDirectoryEntry( @@ -4260,6 +4652,28 @@ function collectBoundedStderr(stream: NodeJS.ReadableStream | null): Promise { + if (!stream) return Promise.resolve(''); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let bytes = 0; + stream.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > maxBytes) { + reject(new Error('Git command output exceeded its byte limit')); + return; + } + chunks.push(buffer); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); +} + async function sha256File(path: string): Promise<`sha256:${string}`> { const hash = createHash('sha256'); for await (const chunk of createReadStream(path)) hash.update(chunk); diff --git a/packages/storage/src/managed-mutation-candidate-authority-internal.ts b/packages/storage/src/managed-mutation-candidate-authority-internal.ts index d94e8a06bc..06fe54cb06 100644 --- a/packages/storage/src/managed-mutation-candidate-authority-internal.ts +++ b/packages/storage/src/managed-mutation-candidate-authority-internal.ts @@ -1,7 +1,7 @@ import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; import type { GitWorkspaceService, ManagedWorkspaceBinding } from './git-workspace-service.js'; -export interface ManagedMutationCandidateRequest { +export interface ManagedMutationCandidateIdentityRequest { readonly binding: ManagedWorkspaceBinding; readonly operationId: string; readonly baseHead: WorkspaceHeadRecordV1; @@ -11,6 +11,16 @@ export interface ManagedMutationCandidateRequest { readonly executionProfileDigest: `sha256:${string}`; } +export interface ManagedMutationCandidateRequest extends ManagedMutationCandidateIdentityRequest { + /** Runtime-owned transform output. Git authority re-hashes this input. */ + readonly expectedContent: string | null; +} + +export interface ManagedMutationBaseFileV1 { + readonly blobOid: string; + readonly content: string; +} + export interface ManagedMutationCandidateReceiptV1 { readonly schemaVersion: 1; readonly protocol: 'maka_managed_mutation_candidate_v1'; @@ -36,11 +46,11 @@ export interface ManagedMutationCandidateReceiptV1 { } export interface ManagedMutationCandidateAuthorityInternal { - readBaseBlob( + readBaseFile( binding: ManagedWorkspaceBinding, baseHead: WorkspaceHeadRecordV1, path: string, - ): Promise; + ): Promise; capture(request: ManagedMutationCandidateRequest): Promise; require( binding: ManagedWorkspaceBinding, diff --git a/packages/storage/src/managed-workspace-execution-authority-internal.ts b/packages/storage/src/managed-workspace-execution-authority-internal.ts index 797b69ead8..f8cb6c594d 100644 --- a/packages/storage/src/managed-workspace-execution-authority-internal.ts +++ b/packages/storage/src/managed-workspace-execution-authority-internal.ts @@ -74,6 +74,7 @@ interface ManagedWorkspaceMutationScopeStateInternal { readonly expectedPaths: readonly string[]; readonly objectFormat: 'sha1' | 'sha256'; readonly baseBlobOid: string | null; + readonly baseContent: string | null; } export type ManagedWorkspaceExecutionScopeStateInternal = diff --git a/packages/storage/src/managed-workspace-owner.ts b/packages/storage/src/managed-workspace-owner.ts index 29eaac1d2c..7fb2a24da4 100644 --- a/packages/storage/src/managed-workspace-owner.ts +++ b/packages/storage/src/managed-workspace-owner.ts @@ -155,6 +155,7 @@ export type ManagedWorkspaceMutationSettlement = interface ManagedMutationExecutionContext { readonly scope: ManagedWorkspaceExecutionScope; resultBlobOid?: string; + resultContent?: string; } export interface ManagedWorkspaceOwner { @@ -559,11 +560,12 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { ); } const canonicalPath = canonicalManagedMutationPath(input.toolName, input.persistedArgs); - const baseBlobOid = await this.mutationCandidateAuthority.readBaseBlob( + const baseFile = await this.mutationCandidateAuthority.readBaseFile( accepted.binding, currentHead, canonicalPath, ); + const baseBlobOid = baseFile?.blobOid ?? null; if (input.toolName === 'Edit' && baseBlobOid === null) { throw new ManagedWorkspaceOwnerError( 'managed_workspace_owner_unavailable', @@ -615,6 +617,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { expectedPaths: durableDispatch.expectedPaths, objectFormat: accepted.binding.objectFormat, baseBlobOid, + baseContent: baseFile?.content ?? null, }); try { const executionContext: ManagedMutationExecutionContext = { scope }; @@ -637,7 +640,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { durableOutcome: proof.durableOutcome, }); } - if (!executionContext.resultBlobOid) { + if (!executionContext.resultBlobOid || executionContext.resultContent === undefined) { throw new ManagedWorkspaceOwnerError( 'managed_workspace_owner_unavailable', 'Managed mutation completed without an exact worker result blob', @@ -651,6 +654,7 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { baseHead: currentHead, expectedPaths: durableDispatch.expectedPaths, expectedBlobOid: executionContext.resultBlobOid, + expectedContent: executionContext.resultContent, executionProfileDigest: durableDispatch.executionProfileDigest, }); } catch (error) { @@ -788,7 +792,9 @@ class ManagedWorkspaceOwnerImpl implements ManagedWorkspaceOwner { } const result = await this.#workerBridge.executeMutation(context.scope, operation, abortSignal); context.resultBlobOid = result.resultBlobOid; - return result; + context.resultContent = result.resultContent; + const { resultContent: _resultContent, ...publicResult } = result; + return publicResult; } close(): Promise { diff --git a/packages/storage/src/managed-workspace-worker-bridge-internal.ts b/packages/storage/src/managed-workspace-worker-bridge-internal.ts index 3b2bdc49a1..e3a82cf59a 100644 --- a/packages/storage/src/managed-workspace-worker-bridge-internal.ts +++ b/packages/storage/src/managed-workspace-worker-bridge-internal.ts @@ -2,10 +2,7 @@ import { createManagedExecutionBoundary, type ExecutionBoundary, } from '@maka/core/sandbox-boundary'; -import { - createReadOnlyPermissionProfile, - createWorkspaceWritePermissionProfile, -} from '@maka/core/permission-profile'; +import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { requireManagedWorkspaceExecutionScopeInternal, type ManagedWorkspaceExecutionScope, @@ -57,8 +54,10 @@ interface ManagedWorkspaceFilesystemWorkerInput { readonly executionBoundary: ExecutionBoundary; readonly abortSignal?: AbortSignal; readonly mutationEvidence?: { + readonly protocol: 'detached_git_transform_v1'; readonly objectFormat: 'sha1' | 'sha256'; readonly baseBlobOid: string | null; + readonly baseContent: string | null; }; } @@ -93,9 +92,17 @@ export type ManagedWorkspaceMutationResult = readonly resultBlobOid: string; }; +type ManagedWorkspaceMutationWorkerResult = ManagedWorkspaceMutationResult & { + readonly resultContent: string; +}; + +type ManagedWorkspaceMutationWorkerOutput = ManagedWorkspaceMutationResult & { + readonly resultContent?: string; +}; + export type ManagedWorkspaceFilesystemResult = | ManagedWorkspaceReadOnlyResult - | ManagedWorkspaceMutationResult; + | ManagedWorkspaceMutationWorkerOutput; export interface ManagedWorkspaceFilesystemWorker { /** Host-issued digest of the exact worker protocol and mutation sandbox profile. */ @@ -104,8 +111,9 @@ export interface ManagedWorkspaceFilesystemWorker { * Resolves only after the one-shot filesystem operation and every process it * owns have reached a terminal lifecycle state. Implementations must not * return a detached filesystem effect to the caller. The production adapter - * satisfies this contract through FilesystemWorkerClient; M1.2 admits only - * read-only operations, so a host crash cannot leave a workspace mutation. + * satisfies this contract through FilesystemWorkerClient. Read operations + * use the owner-bound projection; mutation operations are pure transforms + * over immutable Git content and receive no workspace read/write authority. */ execute(input: ManagedWorkspaceFilesystemWorkerInput): Promise; } @@ -133,7 +141,7 @@ export interface ManagedWorkspaceWorkerBridgeInternal { scope: ManagedWorkspaceExecutionScope, operation: ManagedWorkspaceMutationOperation, abortSignal?: AbortSignal, - ): Promise; + ): Promise; } /** @@ -207,19 +215,19 @@ export function createManagedWorkspaceWorkerBridgeInternal( const result = await worker.execute({ operation, cwd: state.cwd, - executionBoundary: createManagedExecutionBoundary( - createWorkspaceWritePermissionProfile(), - 0, - ), + executionBoundary: createManagedExecutionBoundary(createReadOnlyPermissionProfile(), 0), mutationEvidence: { + protocol: 'detached_git_transform_v1', objectFormat: state.objectFormat, baseBlobOid: state.baseBlobOid, + baseContent: state.baseContent, }, ...(abortSignal ? { abortSignal } : {}), }); if ( !isMutationResult(result) || result.kind !== operation.kind || + typeof result.resultContent !== 'string' || !blobOidMatchesObjectFormat(result.resultBlobOid, state.objectFormat) ) { throw new ManagedWorkspaceWorkerBridgeError( @@ -258,7 +266,7 @@ function isReadOnlyResult( function isMutationResult( input: ManagedWorkspaceFilesystemResult, -): input is ManagedWorkspaceMutationResult { +): input is ManagedWorkspaceMutationWorkerResult { return input.kind === 'write' || input.kind === 'edit'; } diff --git a/scripts/recovery-test-inventory.mjs b/scripts/recovery-test-inventory.mjs index 512522207a..e448114f96 100644 --- a/scripts/recovery-test-inventory.mjs +++ b/scripts/recovery-test-inventory.mjs @@ -50,12 +50,12 @@ export const RECOVERY_TEST_INVENTORIES = Object.freeze({ 'packages/runtime-host/dist/__tests__/managed-workspace-write-edit-crash.test.js', ]), expectedByPlatform: Object.freeze({ - darwin: Object.freeze({ tests: 30, pass: 30, skipped: 0 }), - linux: Object.freeze({ tests: 30, pass: 30, skipped: 0 }), + darwin: Object.freeze({ tests: 31, pass: 31, skipped: 0 }), + linux: Object.freeze({ tests: 31, pass: 31, skipped: 0 }), // The Windows release contains the sandbox broker, but the recovery // runner does not currently build/package that Rust artifact. Keep the // full Host test visible as an explicit platform evidence gap. - win32: Object.freeze({ tests: 30, pass: 29, skipped: 1 }), + win32: Object.freeze({ tests: 31, pass: 30, skipped: 1 }), }), }), }); From 1aea2616198e3ad8c73fbaf4584c4ce96a899ac8 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Wed, 19 Aug 2026 10:24:04 +0800 Subject: [PATCH 7/9] fix(storage): bind projection quarantine identity --- ...e-git-mutation-candidate-owner-v1.zh-CN.md | 4 +- ...orkspace-write-edit-production-v1.zh-CN.md | 9 +- ...naged-mutation-candidate-authority.test.ts | 46 ++++++++++ packages/storage/src/git-workspace-service.ts | 88 +++++++++++++++---- 4 files changed, 129 insertions(+), 18 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md index 6f4641e616..9f15ec27cd 100644 --- a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md @@ -119,7 +119,9 @@ tombstone 在删除 ref 前落盘,所以崩溃后不会把“外部删 ref” 进程崩溃承诺由真实 child-process kill/reopen 测试约束:capture 在 ref publication 后被杀,新进程补齐同一 receipt;discard 在 ref deletion 后被杀,新进程依 tombstone 幂等完成清理;projection rotation 在保存旧目录及 -发布新目录两个点被杀,新进程只收敛投影且保留外部内容。统一的 +发布新目录两个点被杀,新进程只收敛投影且保留外部内容。rotation intent 同时绑定旧 worktree 根目录的 +device/inode identity;恢复拒绝预置 symlink/Windows junction,并且 projection owner 不再通过可替换 quarantine +子路径删除 `.git`。统一的 `scripts/recovery-test-inventory.mjs` 拥有 recovery suite 和三平台期望数量,Linux/macOS/Windows workflow 消费 同一 inventory。嵌套目录的新增、修改与删除均以递归 `diff-tree -r` 的文件路径作为 receipt 证据,不能退化成顶层目录名。 diff --git a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md index 02773b7731..e531000a5a 100644 --- a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md @@ -59,6 +59,11 @@ immutable Git tree。 - candidate capture 后、projection publish 前发生 drift:旧目录整体移入 quarantine,外部内容原样保留; - 新 projection 发布后再发生 drift:最终验证拒绝,不运行 `reset --hard` 覆盖内容。 +rotation intent 在移动前持久化旧 worktree 根目录的 device/inode identity。恢复只接受同一非 symlink +目录出现在确定性 quarantine path;预置 symlink、Windows junction 或其他 identity replacement 一律 fail closed。 +projection owner 不通过 quarantine 子路径删除 `.git` 或其他文件,旧目录作为完整树保留,避免路径在校验后再次 +被替换时产生越界删除。 + 本协议明确禁止“校验 worktree preimage 后再原地覆盖”的 read-check-write,因为跨进程写入无法由进程内锁 线性化。当前 bounded Git/worker transport 只支持 UTF-8 text;二进制或超过 transport 上限的目标在 T1 前 fail closed。 @@ -108,7 +113,7 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | success 但 tree 无变化 | success T2 + terminal fact | 已收敛,head 不变 | | candidate capture 后、SQLite commit 前 | T1 + candidate artifact | reservation 保留;不得对外宣称成功 | | SQLite successor commit 后、projection rotation intent 前 | accepted successor + candidate receipt | reopen 幂等 accept,不重跑工具 | -| rotation 已保存旧目录、尚未发布新投影 | durable rotation intent + quarantined previous projection | reopen 发布新投影;旧内容仍可取回 | +| rotation 已保存旧目录、尚未发布新投影 | durable rotation intent + identity-bound quarantined previous projection | reopen 重验目录 identity 后发布新投影;旧完整树仍可取回 | | rotation 已发布新投影、尚未推进 Git metadata/ref | durable rotation intent + 两份目录 | reopen 只推进 metadata/ref 并验证;不原地覆盖文件 | | Git accept 后、provider publication 前 | accepted successor | Runtime 采用 exact durable outcome;后续 replay 同值 | | 外部修改或 evidence mismatch | 不推进/不覆盖 | quarantine 或 park | @@ -120,7 +125,7 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | T1/reservation/terminal/successor SQLite 原子性 | 承诺 | 承诺 | 承诺 | | exact Write/Edit path + worker profile binding | 承诺 | 承诺 | 实现并由边界测试证明;当前 recovery runner 未打包 broker | | candidate capture/accept process-crash 收敛 | CI 证明 | CI 证明 | CI 证明 | -| 外部并发写不被 projection rotation 覆盖 | rename 后旧 inode/目录进入 quarantine | rename 后旧 inode/目录进入 quarantine | 可 rename 时保留旧目录;打开句柄阻止 rename 时 fail closed | +| 外部并发写不被 projection rotation 覆盖 | rename 后旧 inode/目录进入 quarantine;symlink tamper CI 拒绝 | rename 后旧 inode/目录进入 quarantine;symlink tamper CI 拒绝 | 可 rename 时保留旧目录;junction tamper 拒绝;打开句柄阻止 rename 时 fail closed | | 真实 Host/worker 在 successor commit 后 kill、reopen 不重跑 | CI 证明 | CI 证明 | release broker 存在;当前 recovery runner 明确 skip | | power-loss 后硬件永久写入顺序 | 不承诺 | 不承诺 | 不承诺 | diff --git a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts index dc3ad3c3d2..08dd31ea9c 100644 --- a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts +++ b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts @@ -137,6 +137,52 @@ describe('managed mutation candidate authority', () => { ); }); + test('rejects a tampered Windows quarantine junction without deleting its target metadata', { + skip: process.platform !== 'win32', + }, async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const service = await serviceAt(join(root, 'storage')); + const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(service); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + const digest = receipt.candidateRef.split('/').at(-1)!; + const quarantineRoot = join(dirname(binding.worktreePath), 'projection-quarantine'); + const externalWorktree = join(root, 'external-worktree'); + const externalGitMetadata = join(externalWorktree, '.git'); + await mkdir(quarantineRoot, { recursive: true }); + await mkdir(externalWorktree); + await writeFile(externalGitMetadata, 'external git metadata\n', 'utf8'); + await symlink(externalWorktree, join(quarantineRoot, digest), 'junction'); + + await assert.rejects(authority.accept(binding, receipt)); + assert.equal(await readFile(externalGitMetadata, 'utf8'), 'external git metadata\n'); + }); + + test('rejects a tampered POSIX quarantine symlink without deleting its target metadata', { + skip: process.platform === 'win32', + }, async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const service = await serviceAt(join(root, 'storage')); + const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(service); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + const digest = receipt.candidateRef.split('/').at(-1)!; + const quarantineRoot = join(dirname(binding.worktreePath), 'projection-quarantine'); + const externalWorktree = join(root, 'external-worktree'); + const externalGitMetadata = join(externalWorktree, '.git'); + await mkdir(quarantineRoot, { recursive: true }); + await mkdir(externalWorktree); + await writeFile(externalGitMetadata, 'external git metadata\n', 'utf8'); + await symlink(externalWorktree, join(quarantineRoot, digest), 'dir'); + + await assert.rejects(authority.accept(binding, receipt)); + assert.equal(await readFile(externalGitMetadata, 'utf8'), 'external git metadata\n'); + }); + test('resumes projection rotation after the previous worktree was preserved', async () => { const root = await temporaryRoot(); const sourceRoot = await createEligibleSource(join(root, 'source')); diff --git a/packages/storage/src/git-workspace-service.ts b/packages/storage/src/git-workspace-service.ts index 3fdcf37b64..661962d724 100644 --- a/packages/storage/src/git-workspace-service.ts +++ b/packages/storage/src/git-workspace-service.ts @@ -421,10 +421,16 @@ interface ManagedMutationCandidateDiscardIntentV1 { readonly receipt: ManagedMutationCandidateReceiptV1; } -interface ManagedMutationProjectionIntentV1 { - readonly schemaVersion: 1; - readonly protocol: 'maka_managed_mutation_projection_v1'; +interface ManagedMutationProjectionIntentV2 { + readonly schemaVersion: 2; + readonly protocol: 'maka_managed_mutation_projection_v2'; readonly receipt: ManagedMutationCandidateReceiptV1; + readonly previousWorktreeIdentity: OwnedDirectoryIdentityV1; +} + +interface OwnedDirectoryIdentityV1 { + readonly device: string; + readonly inode: string; } class GitWorkspaceServiceImpl implements GitWorkspaceService { @@ -1219,10 +1225,11 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { encoding: 'utf8', flag: 'wx', }); - const intent: ManagedMutationProjectionIntentV1 = { - schemaVersion: 1, - protocol: 'maka_managed_mutation_projection_v1', + const intent: ManagedMutationProjectionIntentV2 = { + schemaVersion: 2, + protocol: 'maka_managed_mutation_projection_v2', receipt, + previousWorktreeIdentity: await readOwnedDirectoryIdentity(binding.worktreePath), }; await atomicWriteJson(paths.intentPath, intent); await this.input.failpoint?.('after_mutation_projection_intent'); @@ -1266,10 +1273,16 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { 'Managed mutation projection lost both its current and previous worktree', ); } + await assertOwnedDirectoryIdentity(binding.worktreePath, intent.previousWorktreeIdentity); await rename(binding.worktreePath, paths.previousPath); await this.input.failpoint?.('after_mutation_projection_previous'); } - await rm(join(paths.previousPath, '.git'), { force: true }); + // Never remove children through the quarantine pathname. The destination + // can be replaced by an external symlink/junction after any path-based + // check. Rotation therefore authenticates the renamed directory entry and + // leaves its complete tree intact; later quarantine lifecycle work must + // use an equally owner-bound primitive. + await assertOwnedDirectoryIdentity(paths.previousPath, intent.previousWorktreeIdentity); const stableAfterPrevious = await pathEntryExists(binding.worktreePath); if (!stableAfterPrevious) { @@ -3318,15 +3331,16 @@ async function readMutationCandidateDiscardIntent( async function readMutationProjectionIntent( path: string, -): Promise { +): Promise { const value = await readJson(path); if (value === undefined) return undefined; if ( !isRecord(value) || - !hasExactKeys(value, ['schemaVersion', 'protocol', 'receipt']) || - value.schemaVersion !== 1 || - value.protocol !== 'maka_managed_mutation_projection_v1' || - !isMutationCandidateReceipt(value.receipt) + !hasExactKeys(value, ['schemaVersion', 'protocol', 'receipt', 'previousWorktreeIdentity']) || + value.schemaVersion !== 2 || + value.protocol !== 'maka_managed_mutation_projection_v2' || + !isMutationCandidateReceipt(value.receipt) || + !isOwnedDirectoryIdentity(value.previousWorktreeIdentity) ) { throw new GitWorkspaceServiceError( 'managed_workspace_identity_conflict', @@ -3334,14 +3348,15 @@ async function readMutationProjectionIntent( ); } return { - schemaVersion: 1, - protocol: 'maka_managed_mutation_projection_v1', + schemaVersion: 2, + protocol: 'maka_managed_mutation_projection_v2', receipt: value.receipt, + previousWorktreeIdentity: value.previousWorktreeIdentity, }; } function assertMutationProjectionIntent( - intent: ManagedMutationProjectionIntentV1, + intent: ManagedMutationProjectionIntentV2, receipt: ManagedMutationCandidateReceiptV1, ): void { if (!isDeepStrictEqual(intent.receipt, receipt)) { @@ -3352,6 +3367,49 @@ function assertMutationProjectionIntent( } } +function isOwnedDirectoryIdentity(value: unknown): value is OwnedDirectoryIdentityV1 { + return ( + isRecord(value) && + hasExactKeys(value, ['device', 'inode']) && + typeof value.device === 'string' && + /^\d+$/u.test(value.device) && + typeof value.inode === 'string' && + /^\d+$/u.test(value.inode) + ); +} + +async function readOwnedDirectoryIdentity(path: string): Promise { + try { + const info = await lstat(path, { bigint: true }); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('not one regular non-symlink directory entry'); + } + return { + device: info.dev.toString(10), + inode: info.ino.toString(10), + }; + } catch (error) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + `Managed mutation projection directory changed identity: ${path}`, + { cause: error }, + ); + } +} + +async function assertOwnedDirectoryIdentity( + path: string, + expected: OwnedDirectoryIdentityV1, +): Promise { + const actual = await readOwnedDirectoryIdentity(path); + if (actual.device !== expected.device || actual.inode !== expected.inode) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + `Managed mutation projection directory changed identity: ${path}`, + ); + } +} + async function readRepositoryRecord(path: string): Promise { const value = await readJson(path); if (value === undefined) return undefined; From 7323a544511530196a860406cb2608be96a27e2d Mon Sep 17 00:00:00 2001 From: zhiiw Date: Wed, 19 Aug 2026 11:47:31 +0800 Subject: [PATCH 8/9] fix(storage): isolate quarantined projection metadata --- ...e-git-mutation-candidate-owner-v1.zh-CN.md | 8 +- ...orkspace-write-edit-production-v1.zh-CN.md | 26 +- ...naged-mutation-candidate-authority.test.ts | 79 +++- packages/storage/src/git-workspace-service.ts | 351 +++++++++++++----- 4 files changed, 355 insertions(+), 109 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md index 9f15ec27cd..2b9658c869 100644 --- a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md @@ -121,7 +121,13 @@ tombstone 在删除 ref 前落盘,所以崩溃后不会把“外部删 ref” receipt;discard 在 ref deletion 后被杀,新进程依 tombstone 幂等完成清理;projection rotation 在保存旧目录及 发布新目录两个点被杀,新进程只收敛投影且保留外部内容。rotation intent 同时绑定旧 worktree 根目录的 device/inode identity;恢复拒绝预置 symlink/Windows junction,并且 projection owner 不再通过可替换 quarantine -子路径删除 `.git`。统一的 +子路径删除 `.git`。 + +旧投影与新 canonical projection 必须是两个独立的 linked worktree registration:Git owner 通过 +`git worktree move` 保存旧目录,通过 candidate commit 创建新的 detached staging worktree,再把 staging 移到 +canonical path。两者的 per-worktree gitdir、HEAD 与 index 不同;从 quarantine 执行 `reset`/`add` 不得改变 +canonical HEAD/index 或 managed ref。quarantine GC 将来必须先由 Git owner 撤销 registration,再清理目录,不能把 +locked quarantine 当普通缓存目录递归删除。统一的 `scripts/recovery-test-inventory.mjs` 拥有 recovery suite 和三平台期望数量,Linux/macOS/Windows workflow 消费 同一 inventory。嵌套目录的新增、修改与删除均以递归 `diff-tree -r` 的文件路径作为 receipt 证据,不能退化成顶层目录名。 diff --git a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md index e531000a5a..5e285ddb14 100644 --- a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md @@ -61,8 +61,15 @@ immutable Git tree。 rotation intent 在移动前持久化旧 worktree 根目录的 device/inode identity。恢复只接受同一非 symlink 目录出现在确定性 quarantine path;预置 symlink、Windows junction 或其他 identity replacement 一律 fail closed。 -projection owner 不通过 quarantine 子路径删除 `.git` 或其他文件,旧目录作为完整树保留,避免路径在校验后再次 -被替换时产生越界删除。 +rotation 不再用文件系统 rename 复制同一份 `.git` 控制能力,而由 Git owner 执行两次 `git worktree move`:旧投影 +移动为 locked quarantined linked worktree,新 candidate 先物化为具有独立 per-worktree gitdir 的 detached linked +worktree,再移动到 canonical path。这样 quarantine 保留全部外部用户字节,但其中的 `reset`、`add` 等普通 Git +命令只能改变 quarantine 自己的 HEAD/index,不能改变 canonical projection 的 HEAD/index 或 managed ref。 + +staging/quarantine 目录名只使用 operation digest 的固定长度前缀以避免 Windows `MAX_PATH`;完整 digest 仍保存在 +durable intent、receipt 和 lock reason 中,路径名本身不承担 artifact identity。projection owner 不通过可替换的 +quarantine 子路径删除 `.git` 或其他文件。显式绕过 API 修改 Maka 内部 bare repository 属于 storage corruption, +不在“从 quarantine 执行普通 Git 命令”的隔离承诺内,并在后续 reopen 时 fail closed。 本协议明确禁止“校验 worktree preimage 后再原地覆盖”的 read-check-write,因为跨进程写入无法由进程内锁 线性化。当前 bounded Git/worker transport 只支持 UTF-8 text;二进制或超过 transport 上限的目标在 T1 前 @@ -113,8 +120,8 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | success 但 tree 无变化 | success T2 + terminal fact | 已收敛,head 不变 | | candidate capture 后、SQLite commit 前 | T1 + candidate artifact | reservation 保留;不得对外宣称成功 | | SQLite successor commit 后、projection rotation intent 前 | accepted successor + candidate receipt | reopen 幂等 accept,不重跑工具 | -| rotation 已保存旧目录、尚未发布新投影 | durable rotation intent + identity-bound quarantined previous projection | reopen 重验目录 identity 后发布新投影;旧完整树仍可取回 | -| rotation 已发布新投影、尚未推进 Git metadata/ref | durable rotation intent + 两份目录 | reopen 只推进 metadata/ref 并验证;不原地覆盖文件 | +| rotation 已保存旧目录、尚未发布新投影 | durable rotation intent + identity-bound、独立 gitdir 的 quarantined linked worktree | reopen 重验目录与 Git registration/lock 后发布新投影;旧完整树仍可取回 | +| rotation 已发布新投影、尚未推进 managed ref | durable rotation intent + 两个独立 linked worktree | reopen 重验各自 HEAD/index/lock,只推进 managed ref;不原地覆盖文件 | | Git accept 后、provider publication 前 | accepted successor | Runtime 采用 exact durable outcome;后续 replay 同值 | | 外部修改或 evidence mismatch | 不推进/不覆盖 | quarantine 或 park | @@ -125,7 +132,8 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | T1/reservation/terminal/successor SQLite 原子性 | 承诺 | 承诺 | 承诺 | | exact Write/Edit path + worker profile binding | 承诺 | 承诺 | 实现并由边界测试证明;当前 recovery runner 未打包 broker | | candidate capture/accept process-crash 收敛 | CI 证明 | CI 证明 | CI 证明 | -| 外部并发写不被 projection rotation 覆盖 | rename 后旧 inode/目录进入 quarantine;symlink tamper CI 拒绝 | rename 后旧 inode/目录进入 quarantine;symlink tamper CI 拒绝 | 可 rename 时保留旧目录;junction tamper 拒绝;打开句柄阻止 rename 时 fail closed | +| 外部并发写不被 projection rotation 覆盖 | Git worktree move 后旧 inode/目录进入独立 quarantine;symlink tamper CI 拒绝 | Git worktree move 后旧 inode/目录进入独立 quarantine;symlink tamper CI 拒绝 | 可 move 时保留旧目录;junction tamper 拒绝;打开句柄阻止 move 时 fail closed | +| quarantine Git 控制能力 | 独立 per-worktree HEAD/index;reset/add 隔离测试 | 独立 per-worktree HEAD/index;同一协议 | 独立 per-worktree HEAD/index;同一协议 | | 真实 Host/worker 在 successor commit 后 kill、reopen 不重跑 | CI 证明 | CI 证明 | release broker 存在;当前 recovery runner 明确 skip | | power-loss 后硬件永久写入顺序 | 不承诺 | 不承诺 | 不承诺 | @@ -136,9 +144,11 @@ reject 能收敛为 no-effect error terminal;Windows runner 仍执行 30 条 S 因没有构建发布包内的 Rust sandbox broker,完整 Host/worker 用例以一个显式 skip 记录,不能表述为已由该 lane 证明。 这里的承诺是 process-crash convergence,不把普通 `fsync`、Git ref 或 SQLite WAL 夸大为断电级证明。 -Projection quarantine 是有意的恢复边界:M2.4 在成功 rotation 后保留旧目录,从而不会为了“自动恢复”删除 -并发写入或仍被外部 fd 引用的 inode。它会增加磁盘占用;配额、宽限期、诊断与安全 GC 由 M4 的 restore/rebaseline -生命周期 owner 统一实现。在该 owner 落地前,本 API 维持 Draft,不能把无界保留包装成生产级缓存策略。 +Projection quarantine 是有意的恢复边界:M2.4 在成功 rotation 后保留旧目录及其独立、locked per-worktree +metadata,从而不会为了“自动恢复”删除并发写入或仍被外部 fd 引用的 inode,也不会留下指向 canonical HEAD/index +的第二个入口。它会增加磁盘占用;配额、宽限期、诊断与安全 GC 由 M4 的 restore/rebaseline 生命周期 owner +统一实现。M4 必须通过 Git worktree owner 撤销 registration 后再清理,不能直接递归删除路径。在该 owner 落地前, +本 API 维持 Draft,不能把无界保留包装成生产级缓存策略。 ## 6. 用户可见边界 diff --git a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts index 08dd31ea9c..c71b3c5a2c 100644 --- a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts +++ b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts @@ -128,7 +128,7 @@ describe('managed mutation candidate authority', () => { await authority.accept(binding, receipt); assert.equal(await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), 'candidate\n'); - const quarantineRoot = join(dirname(binding.worktreePath), 'projection-quarantine'); + const quarantineRoot = projectionQuarantineRoot(binding); const quarantined = await readdir(quarantineRoot); assert.equal(quarantined.length, 1); assert.equal( @@ -137,6 +137,54 @@ describe('managed mutation candidate authority', () => { ); }); + test('isolates quarantined Git commands from the canonical projection authority', async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const service = await serviceAt(join(root, 'storage')); + const binding = await service.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = await requireManagedBaselineReceiptAuthorityInternal(service).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(service); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + + await writeFile(join(binding.worktreePath, 'tracked.txt'), 'external concurrent content\n'); + await authority.accept(binding, receipt); + + const quarantineRoot = projectionQuarantineRoot(binding); + const quarantined = join(quarantineRoot, (await readdir(quarantineRoot))[0]!); + assert.notEqual( + await realpath( + ( + await git(quarantined, '-c', 'core.longpaths=true', 'rev-parse', '--absolute-git-dir') + ).trim(), + ), + await realpath((await git(binding.worktreePath, 'rev-parse', '--absolute-git-dir')).trim()), + ); + const canonicalHead = (await git(binding.worktreePath, 'rev-parse', 'HEAD')).trim(); + const managedRef = await gitBare( + binding.repositoryPath, + 'rev-parse', + '--verify', + binding.headRef, + ); + await git( + quarantined, + '-c', + 'core.longpaths=true', + 'reset', + '--mixed', + binding.baselineCommitOid, + ); + await writeFile(join(quarantined, 'tracked.txt'), 'quarantined-only\n'); + await git(quarantined, '-c', 'core.longpaths=true', 'add', 'tracked.txt'); + + assert.equal((await git(binding.worktreePath, 'rev-parse', 'HEAD')).trim(), canonicalHead); + assert.equal((await git(binding.worktreePath, 'status', '--porcelain=v1')).trim(), ''); + assert.equal( + await gitBare(binding.repositoryPath, 'rev-parse', '--verify', binding.headRef), + managedRef, + ); + }); + test('rejects a tampered Windows quarantine junction without deleting its target metadata', { skip: process.platform !== 'win32', }, async () => { @@ -148,13 +196,13 @@ describe('managed mutation candidate authority', () => { const authority = requireManagedMutationCandidateAuthorityInternal(service); const receipt = await authority.capture(candidateRequest(binding, baseline)); const digest = receipt.candidateRef.split('/').at(-1)!; - const quarantineRoot = join(dirname(binding.worktreePath), 'projection-quarantine'); + const quarantineRoot = projectionQuarantineRoot(binding); const externalWorktree = join(root, 'external-worktree'); const externalGitMetadata = join(externalWorktree, '.git'); await mkdir(quarantineRoot, { recursive: true }); await mkdir(externalWorktree); await writeFile(externalGitMetadata, 'external git metadata\n', 'utf8'); - await symlink(externalWorktree, join(quarantineRoot, digest), 'junction'); + await symlink(externalWorktree, join(quarantineRoot, digest.slice(0, 20)), 'junction'); await assert.rejects(authority.accept(binding, receipt)); assert.equal(await readFile(externalGitMetadata, 'utf8'), 'external git metadata\n'); @@ -171,13 +219,13 @@ describe('managed mutation candidate authority', () => { const authority = requireManagedMutationCandidateAuthorityInternal(service); const receipt = await authority.capture(candidateRequest(binding, baseline)); const digest = receipt.candidateRef.split('/').at(-1)!; - const quarantineRoot = join(dirname(binding.worktreePath), 'projection-quarantine'); + const quarantineRoot = projectionQuarantineRoot(binding); const externalWorktree = join(root, 'external-worktree'); const externalGitMetadata = join(externalWorktree, '.git'); await mkdir(quarantineRoot, { recursive: true }); await mkdir(externalWorktree); await writeFile(externalGitMetadata, 'external git metadata\n', 'utf8'); - await symlink(externalWorktree, join(quarantineRoot, digest), 'dir'); + await symlink(externalWorktree, join(quarantineRoot, digest.slice(0, 20)), 'dir'); await assert.rejects(authority.accept(binding, receipt)); assert.equal(await readFile(externalGitMetadata, 'utf8'), 'external git metadata\n'); @@ -216,12 +264,9 @@ describe('managed mutation candidate authority', () => { const restarted = await serviceAt(storageRoot); await restarted.openManagedWorkspaceFromBinding(openRequest(sourceRoot)); assert.equal(await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), 'candidate\n'); - const previous = await readdir(join(dirname(binding.worktreePath), 'projection-quarantine')); + const previous = await readdir(projectionQuarantineRoot(binding)); assert.equal( - await readFile( - join(dirname(binding.worktreePath), 'projection-quarantine', previous[0]!, 'tracked.txt'), - 'utf8', - ), + await readFile(join(projectionQuarantineRoot(binding), previous[0]!, 'tracked.txt'), 'utf8'), 'external before crash\n', ); }); @@ -492,15 +537,10 @@ describe('managed mutation candidate authority', () => { await readFile(join(binding.worktreePath, 'docs', 'a.md'), 'utf8'), 'candidate from child\n', ); - const previous = await readdir( - join(dirname(binding.worktreePath), 'projection-quarantine'), - ); + const previous = await readdir(projectionQuarantineRoot(binding)); const preserved = await Promise.all( previous.map((name) => - readFile( - join(dirname(binding.worktreePath), 'projection-quarantine', name, 'docs', 'a.md'), - 'utf8', - ), + readFile(join(projectionQuarantineRoot(binding), name, 'docs', 'a.md'), 'utf8'), ), ); assert.ok(preserved.includes('external before crash\n')); @@ -746,3 +786,8 @@ async function gitBare(repositoryPath: string, ...args: string[]): Promise/r//repository.git. + return join(dirname(dirname(dirname(binding.repositoryPath))), 'q'); +} diff --git a/packages/storage/src/git-workspace-service.ts b/packages/storage/src/git-workspace-service.ts index 661962d724..93d51ad1f4 100644 --- a/packages/storage/src/git-workspace-service.ts +++ b/packages/storage/src/git-workspace-service.ts @@ -13,7 +13,7 @@ import { stat, writeFile, } from 'node:fs/promises'; -import { dirname, isAbsolute, join, normalize, posix, relative, resolve, sep } from 'node:path'; +import { dirname, isAbsolute, join, normalize, posix, relative, resolve } from 'node:path'; import { isDeepStrictEqual, promisify } from 'node:util'; import { isCanonicalManagedMutationPathV1 } from '@maka/core/runtime-event'; import type { WorkspaceHeadRecordV1 } from '@maka/core/workspace-version-authority'; @@ -408,6 +408,7 @@ interface WorkspaceLayout { readonly bindingPath: string; readonly baselineReceiptPath: string; readonly mutationCandidateRoot: string; + readonly projectionStagingRoot: string; readonly projectionQuarantineRoot: string; readonly worktreePath: string; readonly quarantineRoot: string; @@ -1167,76 +1168,25 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { layout: WorkspaceLayout, ): Promise { await ensureOwnedDirectory(layout.mutationCandidateRoot, layout.instanceRoot); - await ensureOwnedDirectory(layout.projectionQuarantineRoot, layout.instanceRoot); + await ensureOwnedDirectory(layout.projectionStagingRoot, layout.managedRoot); + await ensureOwnedDirectory(layout.projectionQuarantineRoot, layout.managedRoot); const paths = mutationProjectionPaths(layout, digest); const existing = await readMutationProjectionIntent(paths.intentPath); if (existing) { assertMutationProjectionIntent(existing, receipt); return; } - await rm(paths.stagingPath, { recursive: true, force: true }); - await rm(paths.indexPath, { force: true }); - await rm(`${paths.indexPath}.lock`, { force: true }); - await mkdir(paths.stagingPath); - const indexEnv = { GIT_INDEX_FILE: paths.indexPath }; - try { - await this.runtime.run( - ['--git-dir', binding.repositoryPath, 'read-tree', receipt.candidateCommitOid], - layout.homePath, - indexEnv, - ); - await this.runtime.run( - [ - '-C', - binding.worktreePath, - '-c', - 'core.bare=false', - '--git-dir', - binding.repositoryPath, - '--work-tree', - binding.worktreePath, - 'checkout-index', - '--all', - '--force', - `--prefix=${paths.stagingPath}${sep}`, - ], - layout.homePath, - indexEnv, - ); - const gitDirectory = ( - await this.runtime.run( - ['-C', binding.worktreePath, 'rev-parse', '--absolute-git-dir'], - layout.homePath, - ) - ).trim(); - const commonDirectory = ( - await this.runtime.run( - ['-C', binding.worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], - layout.homePath, - ) - ).trim(); - if (!samePath(await realpath(commonDirectory), await realpath(binding.repositoryPath))) { - throw new GitWorkspaceServiceError( - 'managed_workspace_identity_conflict', - 'Managed projection Git metadata no longer belongs to its repository', - ); - } - await writeFile(join(paths.stagingPath, '.git'), `gitdir: ${gitDirectory}\n`, { - encoding: 'utf8', - flag: 'wx', - }); - const intent: ManagedMutationProjectionIntentV2 = { - schemaVersion: 2, - protocol: 'maka_managed_mutation_projection_v2', - receipt, - previousWorktreeIdentity: await readOwnedDirectoryIdentity(binding.worktreePath), - }; - await atomicWriteJson(paths.intentPath, intent); - await this.input.failpoint?.('after_mutation_projection_intent'); - } finally { - await rm(paths.indexPath, { force: true }); - await rm(`${paths.indexPath}.lock`, { force: true }); - } + const intent: ManagedMutationProjectionIntentV2 = { + schemaVersion: 2, + protocol: 'maka_managed_mutation_projection_v2', + receipt, + previousWorktreeIdentity: await readOwnedDirectoryIdentity(binding.worktreePath), + }; + // The intent precedes staging publication so a crash during `worktree add` + // can be repaired from the immutable candidate instead of leaving an + // unauthenticated directory that later code has to delete by pathname. + await atomicWriteJson(paths.intentPath, intent); + await this.input.failpoint?.('after_mutation_projection_intent'); } private async convergeMutationProjection( @@ -1264,8 +1214,19 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { assertMutationProjectionIntent(intent, receipt); const stableExists = await pathEntryExists(binding.worktreePath); - const stagingExists = await pathEntryExists(paths.stagingPath); const previousExists = await pathEntryExists(paths.previousPath); + if (previousExists) { + await assertOwnedDirectoryIdentity(paths.previousPath, intent.previousWorktreeIdentity); + } + + const stableRegistration = stableExists + ? await this.readProjectionWorktreeRegistration(binding, binding.worktreePath, layout) + : undefined; + const projectionAlreadyPublished = stableRegistration?.headOid === receipt.candidateCommitOid; + if (!projectionAlreadyPublished) { + await this.ensureMutationProjectionStaging(binding, receipt, digest, layout); + } + if (!previousExists) { if (!stableExists) { throw new GitWorkspaceServiceError( @@ -1274,15 +1235,25 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } await assertOwnedDirectoryIdentity(binding.worktreePath, intent.previousWorktreeIdentity); - await rename(binding.worktreePath, paths.previousPath); + await this.moveManagedProjectionWorktree( + binding, + binding.worktreePath, + paths.previousPath, + receipt.baseHead.commitOid, + worktreeLockReason(binding), + mutationProjectionQuarantineLockReason(digest), + layout, + ); await this.input.failpoint?.('after_mutation_projection_previous'); } - // Never remove children through the quarantine pathname. The destination - // can be replaced by an external symlink/junction after any path-based - // check. Rotation therefore authenticates the renamed directory entry and - // leaves its complete tree intact; later quarantine lifecycle work must - // use an equally owner-bound primitive. await assertOwnedDirectoryIdentity(paths.previousPath, intent.previousWorktreeIdentity); + await this.requireProjectionWorktreeRegistration( + binding, + paths.previousPath, + receipt.baseHead.commitOid, + mutationProjectionQuarantineLockReason(digest), + layout, + ); const stableAfterPrevious = await pathEntryExists(binding.worktreePath); if (!stableAfterPrevious) { @@ -1292,18 +1263,29 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { 'Managed mutation projection staging tree is unavailable', ); } - await rename(paths.stagingPath, binding.worktreePath); + await this.moveManagedProjectionWorktree( + binding, + paths.stagingPath, + binding.worktreePath, + receipt.candidateCommitOid, + mutationProjectionStagingLockReason(digest), + worktreeLockReason(binding), + layout, + ); await this.input.failpoint?.('after_mutation_projection_publish'); - } else if (stagingExists || (await pathEntryExists(paths.stagingPath))) { + } else if (await pathEntryExists(paths.stagingPath)) { throw new GitWorkspaceServiceError( 'managed_workspace_drifted', 'A concurrent writer recreated the managed projection during rotation', ); } - await this.runtime.run( - ['-C', binding.worktreePath, 'reset', '--mixed', receipt.candidateCommitOid], - layout.homePath, + await this.requireProjectionWorktreeRegistration( + binding, + binding.worktreePath, + receipt.candidateCommitOid, + worktreeLockReason(binding), + layout, ); const headRef = ( await this.runtime.run( @@ -1337,7 +1319,196 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } await rm(paths.intentPath, { force: true }); - await rm(paths.stagingPath, { recursive: true, force: true }); + } + + private async ensureMutationProjectionStaging( + binding: ManagedWorkspaceBinding, + receipt: ManagedMutationCandidateReceiptV1, + digest: string, + layout: WorkspaceLayout, + ): Promise { + const paths = mutationProjectionPaths(layout, digest); + const existing = await this.readProjectionWorktreeRegistration( + binding, + paths.stagingPath, + layout, + ); + if (!existing) { + if (await pathEntryExists(paths.stagingPath)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed mutation projection staging path is not an owned Git worktree', + ); + } + await this.runtime.run( + [ + '--git-dir', + binding.repositoryPath, + 'worktree', + 'add', + '--quiet', + '--detach', + paths.stagingPath, + receipt.candidateCommitOid, + ], + layout.homePath, + ); + } + await this.ensureProjectionWorktreeLock( + binding, + paths.stagingPath, + receipt.candidateCommitOid, + mutationProjectionStagingLockReason(digest), + layout, + ); + const [tree, status] = await Promise.all([ + this.runtime.run( + ['-C', paths.stagingPath, 'rev-parse', '--verify', 'HEAD^{tree}'], + layout.homePath, + ), + this.runtime.run( + [ + '-C', + paths.stagingPath, + 'status', + '--porcelain=v1', + '--untracked-files=all', + '--ignored=matching', + ], + layout.homePath, + ), + ]); + if (tree.trim() !== receipt.candidateTreeOid || status.trim() !== '') { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed mutation projection staging worktree changed identity', + ); + } + } + + private async moveManagedProjectionWorktree( + binding: ManagedWorkspaceBinding, + sourcePath: string, + targetPath: string, + expectedHead: string, + sourceLockReason: string, + targetLockReason: string, + layout: WorkspaceLayout, + ): Promise { + const registration = await this.requireProjectionWorktreeRegistration( + binding, + sourcePath, + expectedHead, + sourceLockReason, + layout, + ); + if (registration.lockReason !== undefined) { + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'worktree', 'unlock', sourcePath], + layout.homePath, + ); + } + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'worktree', 'move', sourcePath, targetPath], + layout.homePath, + ); + await this.runtime.run( + [ + '--git-dir', + binding.repositoryPath, + 'worktree', + 'lock', + '--reason', + targetLockReason, + targetPath, + ], + layout.homePath, + ); + } + + private async ensureProjectionWorktreeLock( + binding: ManagedWorkspaceBinding, + worktreePath: string, + expectedHead: string, + expectedLockReason: string, + layout: WorkspaceLayout, + ): Promise { + const registration = await this.readProjectionWorktreeRegistration( + binding, + worktreePath, + layout, + ); + if (!registration || registration.headOid !== expectedHead) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed projection Git worktree registration changed identity', + ); + } + if (registration.lockReason === expectedLockReason) return; + if (registration.lockReason !== undefined) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed projection Git worktree lock changed identity', + ); + } + await this.runtime.run( + [ + '--git-dir', + binding.repositoryPath, + 'worktree', + 'lock', + '--reason', + expectedLockReason, + worktreePath, + ], + layout.homePath, + ); + } + + private async requireProjectionWorktreeRegistration( + binding: ManagedWorkspaceBinding, + worktreePath: string, + expectedHead: string, + expectedLockReason: string, + layout: WorkspaceLayout, + ): Promise { + await this.ensureProjectionWorktreeLock( + binding, + worktreePath, + expectedHead, + expectedLockReason, + layout, + ); + const registration = await this.readProjectionWorktreeRegistration( + binding, + worktreePath, + layout, + ); + if ( + !registration || + registration.headOid !== expectedHead || + registration.lockReason !== expectedLockReason + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Managed projection Git worktree registration is unavailable', + ); + } + return registration; + } + + private async readProjectionWorktreeRegistration( + binding: ManagedWorkspaceBinding, + worktreePath: string, + layout: WorkspaceLayout, + ): Promise { + return findWorktreeRegistration( + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'worktree', 'list', '--porcelain'], + layout.homePath, + ), + worktreePath, + ); } private async resumePendingMutationProjection( @@ -3110,7 +3281,12 @@ function workspaceLayout( bindingPath: join(instanceRoot, 'binding.json'), baselineReceiptPath: join(instanceRoot, 'baseline-receipt.json'), mutationCandidateRoot: join(instanceRoot, 'mutation-candidates'), - projectionQuarantineRoot: join(instanceRoot, 'projection-quarantine'), + // Projection worktrees live directly below the managed root so Git for + // Windows can create its per-worktree gitdir within the platform path + // budget. Full operation identity remains in the durable intent/receipt; + // these compact paths are carriers, not authority. + projectionStagingRoot: join(managedRoot, 'p'), + projectionQuarantineRoot: join(managedRoot, 'q'), worktreePath: join(instanceRoot, 'worktree'), quarantineRoot: join(managedRoot, 'quarantine'), quarantineIntentRoot, @@ -3128,11 +3304,11 @@ function mutationProjectionPaths(layout: WorkspaceLayout, digest: string) { 'Invalid managed mutation projection identity', ); } + const compactDigest = digest.slice(0, 20); return { intentPath: join(layout.mutationCandidateRoot, `${digest}.projection.json`), - stagingPath: join(layout.mutationCandidateRoot, `${digest}.projection-next`), - indexPath: join(layout.mutationCandidateRoot, `${digest}.projection.index`), - previousPath: join(layout.projectionQuarantineRoot, digest), + stagingPath: join(layout.projectionStagingRoot, compactDigest), + previousPath: join(layout.projectionQuarantineRoot, compactDigest), } as const; } @@ -4148,7 +4324,8 @@ async function assertOwnedManagedWorkspaceLayout( await assertOwnedDirectoryEntry(layout.instanceRoot, layout.epochRoot, false); await assertOwnedDirectoryEntry(layout.worktreePath, layout.instanceRoot, false); await assertOwnedDirectoryEntry(layout.mutationCandidateRoot, layout.instanceRoot, false); - await assertOwnedDirectoryEntry(layout.projectionQuarantineRoot, layout.instanceRoot, false); + await assertOwnedDirectoryEntry(layout.projectionStagingRoot, layout.managedRoot, false); + await assertOwnedDirectoryEntry(layout.projectionQuarantineRoot, layout.managedRoot, false); } async function assertOwnedDirectoryEntry( @@ -4443,6 +4620,14 @@ function worktreeLockReason( return `maka managed workspace ${identity.workspaceInstanceId}`; } +function mutationProjectionStagingLockReason(digest: string): string { + return `maka mutation projection staging ${digest}`; +} + +function mutationProjectionQuarantineLockReason(digest: string): string { + return `maka mutation projection quarantine ${digest}`; +} + function assertWorktreeRegistrationLocked( porcelain: string, worktreePath: string, From 6222ae9eb7f70b8a3b5b5abd66a004f53a86bd92 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Thu, 20 Aug 2026 12:18:12 +0800 Subject: [PATCH 9/9] fix(storage): reconcile partial projection worktrees --- ...e-git-mutation-candidate-owner-v1.zh-CN.md | 14 +- ...orkspace-write-edit-production-v1.zh-CN.md | 23 +- ...naged-mutation-candidate-authority.test.ts | 213 +++++++++++- packages/storage/src/git-workspace-service.ts | 328 +++++++++++++++--- 4 files changed, 512 insertions(+), 66 deletions(-) diff --git a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md index 2b9658c869..371e858fef 100644 --- a/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-git-mutation-candidate-owner-v1.zh-CN.md @@ -123,11 +123,15 @@ receipt;discard 在 ref deletion 后被杀,新进程依 tombstone 幂等完 device/inode identity;恢复拒绝预置 symlink/Windows junction,并且 projection owner 不再通过可替换 quarantine 子路径删除 `.git`。 -旧投影与新 canonical projection 必须是两个独立的 linked worktree registration:Git owner 通过 -`git worktree move` 保存旧目录,通过 candidate commit 创建新的 detached staging worktree,再把 staging 移到 -canonical path。两者的 per-worktree gitdir、HEAD 与 index 不同;从 quarantine 执行 `reset`/`add` 不得改变 -canonical HEAD/index 或 managed ref。quarantine GC 将来必须先由 Git owner 撤销 registration,再清理目录,不能把 -locked quarantine 当普通缓存目录递归删除。统一的 +旧投影与新 canonical projection 必须是两个独立的 linked worktree registration。Git owner 不把 porcelain +`worktree add/move` 视为原子事务:staging 以 `worktree add --no-checkout` 分离 registration 与 materialization; +半完成 path/registration 由 durable intent 驱动的 reconciler 整体保留或 prune 后重建。旧目录与 staging 的发布只用 +原子 rename,随后对实际存在的路径幂等执行 `worktree repair` 并重验 HEAD/index/lock。因此 Git command 内部中断不会 +要求重新执行 Write/Edit,也不会把 partial checkout 当成完整 artifact。 + +两份 projection 的 per-worktree gitdir、HEAD 与 index 不同;从 quarantine 执行 `reset`/`add` 不得改变 canonical +HEAD/index 或 managed ref。quarantine GC 将来必须先由 Git owner 撤销 registration,再清理目录,不能把 locked +quarantine 或 partial staging 当普通缓存目录递归删除。统一的 `scripts/recovery-test-inventory.mjs` 拥有 recovery suite 和三平台期望数量,Linux/macOS/Windows workflow 消费 同一 inventory。嵌套目录的新增、修改与删除均以递归 `diff-tree -r` 的文件路径作为 receipt 证据,不能退化成顶层目录名。 diff --git a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md index 5e285ddb14..01c651564a 100644 --- a/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md +++ b/docs/architecture/runtime-managed-workspace-write-edit-production-v1.zh-CN.md @@ -61,10 +61,16 @@ immutable Git tree。 rotation intent 在移动前持久化旧 worktree 根目录的 device/inode identity。恢复只接受同一非 symlink 目录出现在确定性 quarantine path;预置 symlink、Windows junction 或其他 identity replacement 一律 fail closed。 -rotation 不再用文件系统 rename 复制同一份 `.git` 控制能力,而由 Git owner 执行两次 `git worktree move`:旧投影 -移动为 locked quarantined linked worktree,新 candidate 先物化为具有独立 per-worktree gitdir 的 detached linked -worktree,再移动到 canonical path。这样 quarantine 保留全部外部用户字节,但其中的 `reset`、`add` 等普通 Git -命令只能改变 quarantine 自己的 HEAD/index,不能改变 canonical projection 的 HEAD/index 或 managed ref。 +rotation 不把 `git worktree add/move` 当作原子动作。Git owner 先用 `worktree add --no-checkout` 只分配 staging +registration,再单独物化 candidate;registration-only、path-only、registration+incomplete checkout 三种中断状态 +都由同一 reconciler 观察。incomplete staging 整体移入 partial quarantine,缺失路径的 unlocked registration 由 +`worktree prune` 收敛,然后从 immutable candidate 重建,不在半成品上执行覆盖式猜测。 + +发布只使用同一文件系统内的原子目录 rename。rename 后 registration 暂时仍可能指向旧路径,Git owner 根据真实存在 +的 source/target path 幂等执行 `git worktree repair`,再验证 HEAD、index 和 lock。旧投影因此成为 locked +quarantined linked worktree,新 candidate 成为具有独立 per-worktree gitdir 的 canonical linked worktree。这样 +quarantine 保留全部外部用户字节,但其中的 `reset`、`add` 等普通 Git 命令只能改变 quarantine 自己的 HEAD/index, +不能改变 canonical projection 的 HEAD/index 或 managed ref。 staging/quarantine 目录名只使用 operation digest 的固定长度前缀以避免 Windows `MAX_PATH`;完整 digest 仍保存在 durable intent、receipt 和 lock reason 中,路径名本身不承担 artifact identity。projection owner 不通过可替换的 @@ -120,7 +126,9 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | success 但 tree 无变化 | success T2 + terminal fact | 已收敛,head 不变 | | candidate capture 后、SQLite commit 前 | T1 + candidate artifact | reservation 保留;不得对外宣称成功 | | SQLite successor commit 后、projection rotation intent 前 | accepted successor + candidate receipt | reopen 幂等 accept,不重跑工具 | +| staging registration 已创建、checkout 未完成 | durable rotation intent + partial linked-worktree state | 按 path/registration 矩阵整体保留 partial artifact、prune stale registration,再从 candidate 重建 | | rotation 已保存旧目录、尚未发布新投影 | durable rotation intent + identity-bound、独立 gitdir 的 quarantined linked worktree | reopen 重验目录与 Git registration/lock 后发布新投影;旧完整树仍可取回 | +| 目录 rename 已完成、registration 仍指向旧路径 | durable rotation intent + atomic path state | 对实际存在路径重放 `worktree repair`,随后重验 HEAD/index/lock;不重跑工具 | | rotation 已发布新投影、尚未推进 managed ref | durable rotation intent + 两个独立 linked worktree | reopen 重验各自 HEAD/index/lock,只推进 managed ref;不原地覆盖文件 | | Git accept 后、provider publication 前 | accepted successor | Runtime 采用 exact durable outcome;后续 replay 同值 | | 外部修改或 evidence mismatch | 不推进/不覆盖 | quarantine 或 park | @@ -132,13 +140,14 @@ T1 后取消不允许在 operation capability 之前短路。Runtime 仍调用 | T1/reservation/terminal/successor SQLite 原子性 | 承诺 | 承诺 | 承诺 | | exact Write/Edit path + worker profile binding | 承诺 | 承诺 | 实现并由边界测试证明;当前 recovery runner 未打包 broker | | candidate capture/accept process-crash 收敛 | CI 证明 | CI 证明 | CI 证明 | -| 外部并发写不被 projection rotation 覆盖 | Git worktree move 后旧 inode/目录进入独立 quarantine;symlink tamper CI 拒绝 | Git worktree move 后旧 inode/目录进入独立 quarantine;symlink tamper CI 拒绝 | 可 move 时保留旧目录;junction tamper 拒绝;打开句柄阻止 move 时 fail closed | +| 外部并发写不被 projection rotation 覆盖 | atomic rename + worktree repair;旧 inode/目录进入独立 quarantine;symlink tamper CI 拒绝 | atomic rename + worktree repair;旧 inode/目录进入独立 quarantine;symlink tamper CI 拒绝 | 可 rename 时保留旧目录;junction tamper 拒绝;打开句柄阻止 rename 时 fail closed | | quarantine Git 控制能力 | 独立 per-worktree HEAD/index;reset/add 隔离测试 | 独立 per-worktree HEAD/index;同一协议 | 独立 per-worktree HEAD/index;同一协议 | | 真实 Host/worker 在 successor commit 后 kill、reopen 不重跑 | CI 证明 | CI 证明 | release broker 存在;当前 recovery runner 明确 skip | | power-loss 后硬件永久写入顺序 | 不承诺 | 不承诺 | 不承诺 | -统一 recovery inventory 执行真实 child process kill/reopen,并覆盖旧 projection 已移动及新 projection 已发布两个 -rotation crash 点。Linux/macOS 的组合测试经过 Runtime Host、ToolRuntime、 +统一 recovery inventory 执行真实 child process kill/reopen,并覆盖 staging registration 已分配、旧 projection +rename 后 repair 前、旧 projection 已 repair、新 projection rename 后 repair 前及新 projection 已 repair 五个 rotation +crash 点;确定性矩阵测试另覆盖 path-only 与 registration-only fragment。Linux/macOS 的组合测试经过 Runtime Host、ToolRuntime、 真实 `FilesystemWorkerClient`、Git owner 与 SQLite authority,并在 reopen 后执行一个真实 `edit_conflict`,证明 worker reject 能收敛为 no-effect error terminal;Windows runner 仍执行 30 条 SQLite/Git crash 用例,但 因没有构建发布包内的 Rust sandbox broker,完整 Host/worker 用例以一个显式 skip 记录,不能表述为已由该 lane 证明。 diff --git a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts index c71b3c5a2c..8d4c1a6dd7 100644 --- a/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts +++ b/packages/storage/src/__tests__/managed-mutation-candidate-authority.test.ts @@ -8,6 +8,7 @@ import { readFile, readdir, realpath, + rename, rm, symlink, writeFile, @@ -271,6 +272,192 @@ describe('managed mutation candidate authority', () => { ); }); + test('rebuilds an incomplete staging registration after worktree add is interrupted', async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const storageRoot = join(root, 'storage'); + const interrupted = createGitWorkspaceService({ + storageRoot, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + failpoint(point) { + if (point === 'after_mutation_projection_intent') { + throw new Error('simulated process stop before staging registration'); + } + }, + }); + const binding = await interrupted.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = + await requireManagedBaselineReceiptAuthorityInternal(interrupted).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(interrupted); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + await writeFile(join(binding.worktreePath, 'tracked.txt'), 'external before add crash\n'); + + await assert.rejects( + authority.accept(binding, receipt), + /simulated process stop before staging registration/u, + ); + const stagingPath = projectionStagingPath(binding, receipt); + await gitBare( + binding.repositoryPath, + 'worktree', + 'add', + '--quiet', + '--detach', + '--no-checkout', + stagingPath, + receipt.candidateCommitOid, + ); + + const restarted = await serviceAt(storageRoot); + await restarted.openManagedWorkspaceFromBinding(openRequest(sourceRoot)); + assert.equal(await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), 'candidate\n'); + assert.equal((await git(binding.worktreePath, 'status', '--porcelain=v1')).trim(), ''); + }); + + test('repairs a projection registration after its directory rename is interrupted', async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const storageRoot = join(root, 'storage'); + const interrupted = createGitWorkspaceService({ + storageRoot, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + failpoint(point) { + if (point === 'after_mutation_projection_intent') { + throw new Error('simulated process stop before projection rename'); + } + }, + }); + const binding = await interrupted.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = + await requireManagedBaselineReceiptAuthorityInternal(interrupted).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(interrupted); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + await writeFile(join(binding.worktreePath, 'tracked.txt'), 'external before rename crash\n'); + + await assert.rejects( + authority.accept(binding, receipt), + /simulated process stop before projection rename/u, + ); + const previousPath = projectionPreviousPath(binding, receipt); + await gitBare(binding.repositoryPath, 'worktree', 'unlock', binding.worktreePath); + await rename(binding.worktreePath, previousPath); + + const restarted = await serviceAt(storageRoot); + await restarted.openManagedWorkspaceFromBinding(openRequest(sourceRoot)); + assert.equal(await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), 'candidate\n'); + assert.equal( + await readFile(join(previousPath, 'tracked.txt'), 'utf8'), + 'external before rename crash\n', + ); + }); + + test('repairs a projection path when registration advanced before the directory', async () => { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, 'source')); + const storageRoot = join(root, 'storage'); + const interrupted = createGitWorkspaceService({ + storageRoot, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + failpoint(point) { + if (point === 'after_mutation_projection_intent') { + throw new Error('simulated process stop before reverse registration split'); + } + }, + }); + const binding = await interrupted.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = + await requireManagedBaselineReceiptAuthorityInternal(interrupted).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(interrupted); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + await assert.rejects( + authority.accept(binding, receipt), + /simulated process stop before reverse registration split/u, + ); + const previousPath = projectionPreviousPath(binding, receipt); + await gitBare(binding.repositoryPath, 'worktree', 'unlock', binding.worktreePath); + await rename(binding.worktreePath, previousPath); + await gitBare(binding.repositoryPath, 'worktree', 'repair', previousPath); + await rename(previousPath, binding.worktreePath); + + const restarted = await serviceAt(storageRoot); + await restarted.openManagedWorkspaceFromBinding(openRequest(sourceRoot)); + assert.equal(await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), 'candidate\n'); + }); + + test('rebuilds path-only and registration-only staging fragments', async () => { + for (const fragment of ['path-only', 'registration-only'] as const) { + const root = await temporaryRoot(); + const sourceRoot = await createEligibleSource(join(root, `source-${fragment}`)); + const storageRoot = join(root, `storage-${fragment}`); + const interrupted = createGitWorkspaceService({ + storageRoot, + gitRuntime: { + executablePath: gitExecutablePath, + expectedSha256: gitExecutableSha256, + }, + failpoint(point) { + if (point === 'after_mutation_projection_intent') { + throw new Error(`simulated process stop before ${fragment} fragment`); + } + }, + }); + const binding = await interrupted.createManagedWorkspaceFromSource(openRequest(sourceRoot)); + const baseline = + await requireManagedBaselineReceiptAuthorityInternal(interrupted).issue(binding); + const authority = requireManagedMutationCandidateAuthorityInternal(interrupted); + const receipt = await authority.capture(candidateRequest(binding, baseline)); + await assert.rejects( + authority.accept(binding, receipt), + new RegExp(`simulated process stop before ${fragment} fragment`, 'u'), + ); + const stagingPath = projectionStagingPath(binding, receipt); + if (fragment === 'path-only') { + await mkdir(stagingPath); + await writeFile(join(stagingPath, 'partial-marker.txt'), 'preserve me\n'); + } else { + await gitBare( + binding.repositoryPath, + 'worktree', + 'add', + '--quiet', + '--detach', + '--no-checkout', + stagingPath, + receipt.candidateCommitOid, + ); + await rename(stagingPath, join(root, 'registration-path-disappeared')); + } + + const restarted = await serviceAt(storageRoot); + await restarted.openManagedWorkspaceFromBinding(openRequest(sourceRoot)); + assert.equal( + await readFile(join(binding.worktreePath, 'tracked.txt'), 'utf8'), + 'candidate\n', + ); + if (fragment === 'path-only') { + const quarantined = await readdir(projectionQuarantineRoot(binding)); + const preserved = await Promise.all( + quarantined.map((name) => + readFile( + join(projectionQuarantineRoot(binding), name, 'partial-marker.txt'), + 'utf8', + ).catch(() => undefined), + ), + ); + assert.ok(preserved.includes('preserve me\n')); + } + } + }); + test('converges the same candidate when the process stops after ref publication', async () => { const root = await temporaryRoot(); const sourceRoot = await createEligibleSource(join(root, 'source')); @@ -508,7 +695,10 @@ describe('managed mutation candidate authority', () => { timeout: 120_000, }, async () => { for (const failpoint of [ + 'after_mutation_projection_staging_registered', + 'after_mutation_projection_previous_renamed', 'after_mutation_projection_previous', + 'after_mutation_projection_publish_renamed', 'after_mutation_projection_publish', ] as const) { const root = await temporaryRoot(); @@ -540,7 +730,9 @@ describe('managed mutation candidate authority', () => { const previous = await readdir(projectionQuarantineRoot(binding)); const preserved = await Promise.all( previous.map((name) => - readFile(join(projectionQuarantineRoot(binding), name, 'docs', 'a.md'), 'utf8'), + readFile(join(projectionQuarantineRoot(binding), name, 'docs', 'a.md'), 'utf8').catch( + () => undefined, + ), ), ); assert.ok(preserved.includes('external before crash\n')); @@ -692,7 +884,10 @@ function spawnMutationCrashChild(input: { failpoint: | 'after_mutation_candidate_ref' | 'after_mutation_candidate_discard_ref' + | 'after_mutation_projection_staging_registered' + | 'after_mutation_projection_previous_renamed' | 'after_mutation_projection_previous' + | 'after_mutation_projection_publish_renamed' | 'after_mutation_projection_publish'; sourceRoot: string; storageRoot: string; @@ -791,3 +986,19 @@ function projectionQuarantineRoot(binding: ManagedWorkspaceBinding): string { // repository.git is /r//repository.git. return join(dirname(dirname(dirname(binding.repositoryPath))), 'q'); } + +function projectionStagingPath( + binding: ManagedWorkspaceBinding, + receipt: ManagedMutationCandidateReceiptV1, +): string { + const digest = receipt.candidateRef.split('/').at(-1)!; + return join(dirname(dirname(dirname(binding.repositoryPath))), 'p', digest.slice(0, 20)); +} + +function projectionPreviousPath( + binding: ManagedWorkspaceBinding, + receipt: ManagedMutationCandidateReceiptV1, +): string { + const digest = receipt.candidateRef.split('/').at(-1)!; + return join(projectionQuarantineRoot(binding), digest.slice(0, 20)); +} diff --git a/packages/storage/src/git-workspace-service.ts b/packages/storage/src/git-workspace-service.ts index 93d51ad1f4..a37af1a2b7 100644 --- a/packages/storage/src/git-workspace-service.ts +++ b/packages/storage/src/git-workspace-service.ts @@ -234,7 +234,10 @@ export type GitWorkspaceServiceFailpoint = | 'after_mutation_candidate_ref' | 'after_mutation_candidate_discard_ref' | 'after_mutation_projection_intent' + | 'after_mutation_projection_staging_registered' + | 'after_mutation_projection_previous_renamed' | 'after_mutation_projection_previous' + | 'after_mutation_projection_publish_renamed' | 'after_mutation_projection_publish'; export interface ManagedWorkspaceIdentity { @@ -1219,6 +1222,18 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { await assertOwnedDirectoryIdentity(paths.previousPath, intent.previousWorktreeIdentity); } + if (previousExists && stableExists) { + await this.relocateManagedProjectionWorktree( + binding, + paths.stagingPath, + binding.worktreePath, + receipt.candidateCommitOid, + mutationProjectionStagingLockReason(digest), + worktreeLockReason(binding), + 'after_mutation_projection_publish_renamed', + layout, + ); + } const stableRegistration = stableExists ? await this.readProjectionWorktreeRegistration(binding, binding.worktreePath, layout) : undefined; @@ -1235,19 +1250,20 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } await assertOwnedDirectoryIdentity(binding.worktreePath, intent.previousWorktreeIdentity); - await this.moveManagedProjectionWorktree( + await this.relocateManagedProjectionWorktree( binding, binding.worktreePath, paths.previousPath, receipt.baseHead.commitOid, worktreeLockReason(binding), mutationProjectionQuarantineLockReason(digest), + 'after_mutation_projection_previous_renamed', layout, ); await this.input.failpoint?.('after_mutation_projection_previous'); } await assertOwnedDirectoryIdentity(paths.previousPath, intent.previousWorktreeIdentity); - await this.requireProjectionWorktreeRegistration( + await this.repairAndRequireProjectionWorktreeRegistration( binding, paths.previousPath, receipt.baseHead.commitOid, @@ -1263,13 +1279,14 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { 'Managed mutation projection staging tree is unavailable', ); } - await this.moveManagedProjectionWorktree( + await this.relocateManagedProjectionWorktree( binding, paths.stagingPath, binding.worktreePath, receipt.candidateCommitOid, mutationProjectionStagingLockReason(digest), worktreeLockReason(binding), + 'after_mutation_projection_publish_renamed', layout, ); await this.input.failpoint?.('after_mutation_projection_publish'); @@ -1280,7 +1297,7 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { ); } - await this.requireProjectionWorktreeRegistration( + await this.repairAndRequireProjectionWorktreeRegistration( binding, binding.worktreePath, receipt.candidateCommitOid, @@ -1328,18 +1345,75 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { layout: WorkspaceLayout, ): Promise { const paths = mutationProjectionPaths(layout, digest); - const existing = await this.readProjectionWorktreeRegistration( - binding, - paths.stagingPath, - layout, - ); - if (!existing) { - if (await pathEntryExists(paths.stagingPath)) { - throw new GitWorkspaceServiceError( - 'managed_workspace_identity_conflict', - 'Managed mutation projection staging path is not an owned Git worktree', + for (let attempt = 0; attempt < 3; attempt += 1) { + const registration = await this.readProjectionWorktreeRegistration( + binding, + paths.stagingPath, + layout, + ); + const pathExists = await pathEntryExists(paths.stagingPath); + + if (registration && !pathExists) { + if (registration.lockReason !== undefined) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Missing projection staging path still has a locked Git registration', + ); + } + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'worktree', 'prune', '--expire=now'], + layout.homePath, ); + if (await this.readProjectionWorktreeRegistration(binding, paths.stagingPath, layout)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Incomplete projection staging registration could not be pruned', + ); + } + continue; } + + if (pathExists) { + await assertOwnedDirectoryEntry(paths.stagingPath, layout.projectionStagingRoot, true); + if ( + registration?.headOid === receipt.candidateCommitOid && + (registration.lockReason === undefined || + registration.lockReason === mutationProjectionStagingLockReason(digest)) + ) { + const [tree, status] = await Promise.all([ + this.runtime.run( + ['-C', paths.stagingPath, 'rev-parse', '--verify', 'HEAD^{tree}'], + layout.homePath, + ), + this.readProjectionWorktreeStatus(paths.stagingPath, layout), + ]); + if (tree.trim() === receipt.candidateTreeOid && status === '') { + await this.ensureProjectionWorktreeLock( + binding, + paths.stagingPath, + receipt.candidateCommitOid, + mutationProjectionStagingLockReason(digest), + layout, + ); + return; + } + } else if (registration) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Incomplete projection staging registration changed identity', + ); + } + await this.preserveIncompleteProjectionStaging( + binding, + paths.stagingPath, + registration, + receipt.candidateCommitOid, + digest, + layout, + ); + continue; + } + await this.runtime.run( [ '--git-dir', @@ -1348,82 +1422,226 @@ class GitWorkspaceServiceImpl implements GitWorkspaceService { 'add', '--quiet', '--detach', + '--no-checkout', paths.stagingPath, receipt.candidateCommitOid, ], layout.homePath, ); + await this.input.failpoint?.('after_mutation_projection_staging_registered'); + await this.runtime.run( + ['-C', paths.stagingPath, 'reset', '--hard', receipt.candidateCommitOid], + layout.homePath, + ); + const [tree, status] = await Promise.all([ + this.runtime.run( + ['-C', paths.stagingPath, 'rev-parse', '--verify', 'HEAD^{tree}'], + layout.homePath, + ), + this.readProjectionWorktreeStatus(paths.stagingPath, layout), + ]); + if (tree.trim() !== receipt.candidateTreeOid || status !== '') { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'New projection staging worktree did not materialize the candidate', + ); + } + await this.ensureProjectionWorktreeLock( + binding, + paths.stagingPath, + receipt.candidateCommitOid, + mutationProjectionStagingLockReason(digest), + layout, + ); + return; } - await this.ensureProjectionWorktreeLock( + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Managed mutation projection staging did not converge', + ); + } + + private async preserveIncompleteProjectionStaging( + binding: ManagedWorkspaceBinding, + stagingPath: string, + registration: WorktreeRegistration | undefined, + expectedHead: string, + digest: string, + layout: WorkspaceLayout, + ): Promise { + if (registration) { + if ( + registration.headOid !== expectedHead || + (registration.lockReason !== undefined && + registration.lockReason !== mutationProjectionStagingLockReason(digest)) + ) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Incomplete projection staging artifact changed Git identity', + ); + } + if (registration.lockReason !== undefined) { + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'worktree', 'unlock', stagingPath], + layout.homePath, + ); + } + } + const preservedPath = await moveToQuarantine( + stagingPath, + layout.projectionQuarantineRoot, + `partial-projection-${digest}`, + ); + if (registration || (await pathEntryExists(join(preservedPath, '.git')))) { + try { + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'worktree', 'repair', preservedPath], + layout.homePath, + ); + } catch (error) { + // A path-only interruption may contain an incomplete or absent `.git` + // pointer with no central registration to repair. The whole directory + // is already quarantined and cannot alias the future canonical + // worktree, so retain it as inert evidence. A known registration must + // always remain repairable. + if (registration) throw error; + } + } + const preservedRegistration = await this.readProjectionWorktreeRegistration( binding, - paths.stagingPath, - receipt.candidateCommitOid, - mutationProjectionStagingLockReason(digest), + preservedPath, layout, ); - const [tree, status] = await Promise.all([ - this.runtime.run( - ['-C', paths.stagingPath, 'rev-parse', '--verify', 'HEAD^{tree}'], - layout.homePath, - ), - this.runtime.run( + if (preservedRegistration) { + await this.ensureProjectionWorktreeLock( + binding, + preservedPath, + expectedHead, + mutationProjectionPartialLockReason(digest), + layout, + ); + } + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'worktree', 'prune', '--expire=now'], + layout.homePath, + ); + if (await this.readProjectionWorktreeRegistration(binding, stagingPath, layout)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Incomplete projection staging registration still owns its source path', + ); + } + } + + private async readProjectionWorktreeStatus( + worktreePath: string, + layout: WorkspaceLayout, + ): Promise { + return ( + await this.runtime.run( [ '-C', - paths.stagingPath, + worktreePath, 'status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching', ], layout.homePath, - ), - ]); - if (tree.trim() !== receipt.candidateTreeOid || status.trim() !== '') { - throw new GitWorkspaceServiceError( - 'managed_workspace_identity_conflict', - 'Managed mutation projection staging worktree changed identity', - ); - } + ) + ).trim(); } - private async moveManagedProjectionWorktree( + private async relocateManagedProjectionWorktree( binding: ManagedWorkspaceBinding, sourcePath: string, targetPath: string, expectedHead: string, sourceLockReason: string, targetLockReason: string, + afterRenameFailpoint: + | 'after_mutation_projection_previous_renamed' + | 'after_mutation_projection_publish_renamed', layout: WorkspaceLayout, ): Promise { - const registration = await this.requireProjectionWorktreeRegistration( - binding, - sourcePath, - expectedHead, - sourceLockReason, - layout, - ); - if (registration.lockReason !== undefined) { + const sourceExists = await pathEntryExists(sourcePath); + const targetExists = await pathEntryExists(targetPath); + if (sourceExists && targetExists) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Projection relocation source and target both exist', + ); + } + if (!sourceExists && !targetExists) { + throw new GitWorkspaceServiceError( + 'managed_workspace_unavailable', + 'Projection relocation lost both source and target paths', + ); + } + + if (sourceExists) { + // Repair is deliberately driven from the directory that actually + // exists. A prior killed repair may already name the target in central + // registration metadata even though the atomic rename still presents + // the source path. await this.runtime.run( - ['--git-dir', binding.repositoryPath, 'worktree', 'unlock', sourcePath], + ['--git-dir', binding.repositoryPath, 'worktree', 'repair', sourcePath], layout.homePath, ); + const registration = await this.requireProjectionWorktreeRegistration( + binding, + sourcePath, + expectedHead, + sourceLockReason, + layout, + ); + if (registration.lockReason !== undefined) { + await this.runtime.run( + ['--git-dir', binding.repositoryPath, 'worktree', 'unlock', sourcePath], + layout.homePath, + ); + } + await rename(sourcePath, targetPath); + await this.input.failpoint?.(afterRenameFailpoint); } + await this.runtime.run( - ['--git-dir', binding.repositoryPath, 'worktree', 'move', sourcePath, targetPath], + ['--git-dir', binding.repositoryPath, 'worktree', 'repair', targetPath], layout.homePath, ); + await this.ensureProjectionWorktreeLock( + binding, + targetPath, + expectedHead, + targetLockReason, + layout, + ); + if (await this.readProjectionWorktreeRegistration(binding, sourcePath, layout)) { + throw new GitWorkspaceServiceError( + 'managed_workspace_identity_conflict', + 'Projection relocation retained its obsolete source registration', + ); + } + } + + private async repairAndRequireProjectionWorktreeRegistration( + binding: ManagedWorkspaceBinding, + worktreePath: string, + expectedHead: string, + expectedLockReason: string, + layout: WorkspaceLayout, + ): Promise { await this.runtime.run( - [ - '--git-dir', - binding.repositoryPath, - 'worktree', - 'lock', - '--reason', - targetLockReason, - targetPath, - ], + ['--git-dir', binding.repositoryPath, 'worktree', 'repair', worktreePath], layout.homePath, ); + return this.requireProjectionWorktreeRegistration( + binding, + worktreePath, + expectedHead, + expectedLockReason, + layout, + ); } private async ensureProjectionWorktreeLock( @@ -4628,6 +4846,10 @@ function mutationProjectionQuarantineLockReason(digest: string): string { return `maka mutation projection quarantine ${digest}`; } +function mutationProjectionPartialLockReason(digest: string): string { + return `maka mutation projection partial ${digest}`; +} + function assertWorktreeRegistrationLocked( porcelain: string, worktreePath: string,